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 d825721b3b [python] Align data directories with Java and enable Native 
IO (#10249)
d825721b3b is described below

commit d825721b3bb608588da875b647005240bffb059b
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 28 13:15:02 2026 +0800

    [python] Align data directories with Java and enable Native IO (#10249)
---
 .../pypaimon/common/external_path_provider.py      |  10 +-
 .../pypaimon/manifest/schema/data_file_meta.py     |   7 +-
 paimon-python/pypaimon/read/split_serializer.py    |   6 +-
 paimon-python/pypaimon/read/table_read.py          |   5 -
 paimon-python/pypaimon/read/table_scan.py          |   9 -
 paimon-python/pypaimon/table/file_store_table.py   |   5 +-
 .../tests/data_file_path_directory_test.py         | 194 ++++++++++++------
 .../pypaimon/tests/deletion_vector_path_test.py    |   3 +-
 .../pypaimon/tests/external_paths_test.py          |   2 +-
 .../pypaimon/tests/native_data_directory_test.py   | 226 +++++++++++++++++++++
 paimon-python/pypaimon/tests/native_write_test.py  |  13 +-
 .../pypaimon/tests/table_upsert_by_key_test.py     |   2 +
 .../pypaimon/utils/file_store_path_factory.py      |  23 ++-
 paimon-python/pypaimon/utils/path.py               | 117 +++++++++++
 paimon-python/pypaimon/write/native_update.py      |   1 -
 paimon-python/pypaimon/write/table_commit.py       |   5 -
 paimon-python/pypaimon/write/table_update.py       |   1 -
 paimon-python/pypaimon/write/write_builder.py      |   6 -
 18 files changed, 519 insertions(+), 116 deletions(-)

diff --git a/paimon-python/pypaimon/common/external_path_provider.py 
b/paimon-python/pypaimon/common/external_path_provider.py
index e039b1d86e..b32538d345 100644
--- a/paimon-python/pypaimon/common/external_path_provider.py
+++ b/paimon-python/pypaimon/common/external_path_provider.py
@@ -22,6 +22,8 @@ import struct
 from abc import ABC, abstractmethod
 from typing import List
 
+from pypaimon.utils.path import resolve_path, to_file_io_path
+
 
 class ExternalPathProvider(ABC):
     """Provider for external data paths."""
@@ -71,7 +73,7 @@ class RoundRobinExternalPathProvider(ExternalPathProvider):
 
         external_base = self._external_table_paths[self._position]
         if self._relative_bucket_path:
-            return 
f"{external_base.rstrip('/')}/{self._relative_bucket_path.strip('/')}/{file_name}"
+            return to_file_io_path(resolve_path(resolve_path(external_base, 
self._relative_bucket_path), file_name))
         else:
             return f"{external_base.rstrip('/')}/{file_name}"
 
@@ -97,7 +99,7 @@ class EntropyInjectExternalPathProvider(ExternalPathProvider):
     def get_next_external_data_path(self, file_name: str) -> str:
         hash_dirs = self._compute_hash(file_name)
         if self._relative_bucket_path:
-            file_path_with_hash = 
f"{self._relative_bucket_path.strip('/')}/{hash_dirs}/{file_name}"
+            file_path_with_hash = resolve_path(self._relative_bucket_path, 
f"{hash_dirs}/{file_name}")
         else:
             file_path_with_hash = f"{hash_dirs}/{file_name}"
 
@@ -106,7 +108,7 @@ class 
EntropyInjectExternalPathProvider(ExternalPathProvider):
             self._position = 0
 
         external_base = self._external_table_paths[self._position]
-        return f"{external_base.rstrip('/')}/{file_path_with_hash}"
+        return to_file_io_path(resolve_path(external_base, 
file_path_with_hash))
 
     def _compute_hash(self, file_name: str) -> str:
         hash_int = _murmur3_32(file_name.encode('utf-8'))
@@ -151,7 +153,7 @@ class WeightedExternalPathProvider(ExternalPathProvider):
             index = len(self._external_table_paths) - 1
         selected_base = self._external_table_paths[index]
         if self._relative_bucket_path:
-            return 
f"{selected_base.rstrip('/')}/{self._relative_bucket_path.strip('/')}/{file_name}"
+            return to_file_io_path(resolve_path(resolve_path(selected_base, 
self._relative_bucket_path), file_name))
         else:
             return f"{selected_base.rstrip('/')}/{file_name}"
 
diff --git a/paimon-python/pypaimon/manifest/schema/data_file_meta.py 
b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
index 6fd0a6a4f2..4cefe7048c 100644
--- a/paimon-python/pypaimon/manifest/schema/data_file_meta.py
+++ b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
@@ -145,9 +145,10 @@ class DataFileMeta:
             self, table_path: str, partition: GenericRow, bucket: int,
             default_part_value: str = "__DEFAULT_PARTITION__",
             data_file_path_directory: Optional[str] = None):
-        path_builder = table_path.rstrip('/')
-        if data_file_path_directory:
-            path_builder = f"{path_builder}/{data_file_path_directory}"
+        from pypaimon.utils.path import resolve_path, to_file_io_path
+        path_builder = resolve_path(table_path, data_file_path_directory)
+        if data_file_path_directory is not None:
+            path_builder = to_file_io_path(path_builder)
         partition_dict = partition.to_dict()
         for field_name, field_value in partition_dict.items():
             part_value = default_part_value if 
_is_null_or_whitespace_only(field_value) else str(field_value)
diff --git a/paimon-python/pypaimon/read/split_serializer.py 
b/paimon-python/pypaimon/read/split_serializer.py
index c329a34e2f..d7360df7c0 100644
--- a/paimon-python/pypaimon/read/split_serializer.py
+++ b/paimon-python/pypaimon/read/split_serializer.py
@@ -36,6 +36,7 @@ from pypaimon.table.row.binary_row import BinaryRow
 from pypaimon.table.row.generic_row import (
     GenericRow, GenericRowDeserializer, GenericRowSerializer)
 from pypaimon.table.source.deletion_file import DeletionFile
+from pypaimon.utils.path import to_file_io_path
 from pypaimon.utils.range import Range
 
 # Frame magics/versions, mirroring the Rust/Java constants.
@@ -654,7 +655,8 @@ def _datafilemeta_from_row(row_bytes: bytes, bucket_path: 
str, arity: int,
         write_cols_sequences=(
             _decode_non_null_long_array(g(20)) if arity >= 21 else None),
     )
-    meta.file_path = external_path if external_path else "%s/%s" % 
(bucket_path.rstrip('/'), file_name)
+    meta.file_path = external_path if external_path else to_file_io_path(
+        "%s/%s" % (bucket_path.rstrip('/'), file_name))
     return meta
 
 
@@ -691,7 +693,7 @@ def _read_deletion_list(r: _Reader) -> 
Optional[List[Optional[DeletionFile]]]:
         path = r.java_utf()
         offset, length, cardinality = r.i64(), r.i64(), r.i64()
         result.append(DeletionFile(
-            dv_index_path=path,
+            dv_index_path=to_file_io_path(path),
             offset=offset,
             length=length,
             cardinality=None if cardinality == -1 else cardinality,
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 781d05e90c..3ec796c68e 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -433,11 +433,6 @@ class TableRead:
         """Return Rust-read batches, or ``None`` when this read must fall 
back."""
         if not self.table.options.native_read_enabled():
             return None
-        # data-file.path-directory relocates data files under a sub-directory
-        # the native reader resolves at the bucket root -- it would 404. The
-        # Python reader honors the directory, matching the write/plan fallback.
-        if self.table.options.data_file_path_directory() is not None:
-            return None
         if self.table.options.file_format() not in _NATIVE_READ_FILE_FORMATS:
             return None
         if not splits:
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 4bddf3345b..344e1a1703 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -106,15 +106,6 @@ class TableScan:
         )
         if not native_runtime_available():
             return False
-        # ``data-file.path-directory`` relocates data files under a
-        # sub-directory that only the Python write/plan paths resolve (see
-        # FileStoreTable and the split generators). The native planner still
-        # resolves files at the bucket root, so a native plan would read the
-        # wrong location and fail with NotFound. Fall back to the Python
-        # scanner -- which honors the directory -- until the native runtime
-        # learns this option.
-        if self.table.options.data_file_path_directory() is not None:
-            return False
         fs = self.file_scanner
         if not self._native_global_index_result_supported():
             return False
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index d0ec91f960..85c9c5775b 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -534,12 +534,15 @@ class FileStoreTable(Table):
         return table
 
     def _copy(self, options: dict, resolve_time_travel: bool) -> 
'FileStoreTable':
+        directory_key = CoreOptions.DATA_FILE_PATH_DIRECTORY.key()
+        if directory_key in options and options[directory_key] != 
self.options.data_file_path_directory():
+            raise ValueError('Cannot change immutable option ' + directory_key)
         if CoreOptions.BUCKET.key() in options and 
int(options.get(CoreOptions.BUCKET.key())) != self.options.bucket():
             raise ValueError("Cannot change bucket number")
         new_options = CoreOptions.copy(self.options).options.to_map()
         for k, v in options.items():
             if v is None:
-                new_options.pop(k)
+                new_options.pop(k, None)
             else:
                 new_options[k] = v
 
diff --git a/paimon-python/pypaimon/tests/data_file_path_directory_test.py 
b/paimon-python/pypaimon/tests/data_file_path_directory_test.py
index c2fbccd925..3920a3c34a 100644
--- a/paimon-python/pypaimon/tests/data_file_path_directory_test.py
+++ b/paimon-python/pypaimon/tests/data_file_path_directory_test.py
@@ -21,6 +21,8 @@ import os
 import shutil
 import tempfile
 import unittest
+
+import pytest
 from unittest import mock
 
 import pyarrow as pa
@@ -96,79 +98,35 @@ class DataFilePathDirectoryTest(unittest.TestCase):
         result = read_builder.new_read().to_arrow(splits)
         self.assertEqual(sorted(result.column("id").to_pylist()), [1, 2, 3])
 
-    def test_native_plan_falls_back_when_directory_configured(self):
-        # ``data-file.path-directory`` only resolves under Python planning;
-        # the native planner still looks under the bucket root and would 404.
-        # Even with scan.native-plan.enabled the scan must refuse native
-        # planning and fall back to Python, so the relocated files are still
-        # found. Regression for the Python/Rust Plan divergence raised in
-        # review (native read failed with NotFound before the guard).
-        self.catalog.create_table(
-            "db.t_native",
-            Schema(fields=Schema.from_pyarrow_schema(pa.schema([
-                ("id", pa.int32()), ("v", pa.string())])).fields,
-                options={"data-file.path-directory": "data",
-                         "scan.native-plan.enabled": "true"}),
-            False,
-        )
-        table = self._write("db.t_native")
-
+    def test_native_plan_accepts_configured_directory(self):
+        table = self._create_with_options(
+            "db.t_native", {"data-file.path-directory": "data"})
         scan = table.new_read_builder().new_scan()
-        # Force the runtime probe to succeed so the assertion isolates the
-        # directory guard (not merely a missing pypaimon-rust): the gate must
-        # still refuse native planning because the directory is configured.
-        with mock.patch(
-                "pypaimon.read.native_plan.native_runtime_available",
-                return_value=True):
-            self.assertFalse(scan._native_plan_supported())
-
-        # End-to-end: a native-requested scan returns the rows through the
-        # Python fallback rather than failing to locate the relocated files.
-        splits = scan.plan().splits()
-        result = table.new_read_builder().new_read().to_arrow(splits)
-        self.assertEqual(sorted(result.column("id").to_pylist()), [1, 2, 3])
+        with mock.patch("pypaimon.read.native_plan.native_runtime_available", 
return_value=True):
+            self.assertTrue(scan._native_plan_supported())
 
-    def test_native_write_falls_back_when_directory_configured(self):
-        # write.native.enabled + data-file.path-directory: the write builder
-        # must return the Python writer (native probe returns None) so the
-        # relocated directory is honored; the native writer writes at the root.
-        # Patch the native constructor so the guard, not a missing runtime, is
-        # what forces the fallback (without the guard this returns the 
sentinel).
-        table = self._create_with_options(
-            "db.t_native_write",
-            {"data-file.path-directory": "data",
-             "write.native.enabled": "true"})
-        with mock.patch(
-                "pypaimon.write.native_write.create_native_write",
-                return_value=object()):
-            self.assertIsNone(table.new_batch_write_builder()._native_write())
-
-    def test_native_read_falls_back_when_directory_configured(self):
-        # read.native.enabled + data-file.path-directory: the native read
-        # probe short-circuits to the Python reader before loading the Rust
-        # runtime, so the relocated files are resolved.
+    def test_native_write_dispatches_with_configured_directory(self):
         table = self._create_with_options(
-            "db.t_native_read",
-            {"data-file.path-directory": "data",
-             "read.native.enabled": "true"})
-        self._write("db.t_native_read")
-        rb = table.new_read_builder()
-        splits = rb.new_scan().plan().splits()
-        self.assertIsNone(rb.new_read()._try_native_batches(
-            splits, pa.schema([("id", pa.int32()), ("v", pa.string())])))
-
-    def test_native_commit_falls_back_when_directory_configured(self):
-        # commit.native.enabled + data-file.path-directory: the native commit
-        # probe returns None so the Python committer records the relocated
-        # paths.
+            "db.t_native_write", {"data-file.path-directory": "data",
+                                  "write.native.enabled": "true"})
+        sentinel = object()
+        with mock.patch("pypaimon.write.native_write.create_native_write", 
return_value=sentinel):
+            self.assertIs(table.new_batch_write_builder()._native_write(), 
sentinel)
+
+    def test_native_commit_dispatches_with_configured_directory(self):
         table = self._create_with_options(
-            "db.t_native_commit",
-            {"data-file.path-directory": "data",
-             "commit.native.enabled": "true"})
+            "db.t_native_commit", {"data-file.path-directory": "data",
+                                   "commit.native.enabled": "true"})
         commit = table.new_batch_write_builder().new_commit()
+        sentinel = object()
         try:
-            self.assertIsNone(commit._prepare_native_commit([]))
+            with 
mock.patch("pypaimon.write.native_commit.native_messages_supported", 
return_value=True), \
+                    
mock.patch("pypaimon.write.native_commit.create_native_commit", 
return_value=sentinel), \
+                    
mock.patch("pypaimon.write.native_commit.to_native_commit_messages", 
return_value=[]):
+                self.assertEqual(commit._prepare_native_commit([]), (sentinel, 
[]))
         finally:
+            # The sentinel represents a prepared committer, not a real 
resource.
+            commit._native_commit = None
             commit.close()
 
     def test_default_keeps_data_files_at_bucket_root(self):
@@ -190,3 +148,107 @@ class DataFilePathDirectoryTest(unittest.TestCase):
 
 if __name__ == "__main__":
     unittest.main()
+
+
+# Expected strings were produced by org.apache.paimon.fs.Path from this tree.
[email protected]('parent,child,expected', [
+    ('/warehouse/t', 'data/nested', '/warehouse/t/data/nested'),
+    ('/warehouse/t/', 'data//discard/../nested/.', '/warehouse/t/data/nested'),
+    ('s3://bucket/warehouse/t', '/shared/data', 's3://bucket/shared/data'),
+    ('s3://bucket/t', 's3://other/data', 's3://other/data'),
+    ('file:/warehouse/t', 'file:///other/data', 'file:/other/data'),
+    ('memory:/t', '../other', 'memory:/other'),
+    ('warehouse/t', '../data', 'warehouse/data'),
+    ('warehouse/t', 'data', 'warehouse/t/data'),
+    ('s3://bucket/t', '//other/data', 's3://other/data'),
+    ('s3://bucket/t', 'data 100%?#/你好', 's3://bucket/t/data 100%?#/你好'),
+    ('file:/t', './data', 'file:/t/data'),
+    ('/', 'data', '/data'),
+    ('/t', '.', '/t'),
+    ('/t', '..', '/'),
+    ('/t', '../../data', '/../data'),
+    ('s3://bucket/', 'data', 's3://bucket/data'),
+    ('s3://bucket', '.', 's3://bucket'),
+    ('s3://bucket', 'x/..', 's3://bucket'),
+    ('warehouse', 'a/../b:c', 'warehouse/b:c'),
+    ('memory:/t', 'a/../../b', 'memory:/b'),
+    ('/t', '///data', '/data'),
+    ('/t', '////data', '/data'),
+    ('file:/t', 'file:////data', 'file:/data'),
+    ('/t', '//', '/'),
+    ('file:/t', '//', 'file:/'),
+    ('s3://bucket/t', '//other:9000/data', 's3://other:9000/data'),
+    ('//host:8020/table', '//other/data', '//other/data'),
+    ('relative', '../b:c', './b:c'),
+    ('relative', '../a:bb', './a:bb'),
+    ('relative', '../b:c/x', './b:c/x'),
+    ('file:/t', 'file:/data', 'file:/data'),
+])
[email protected]('windows', [False, True])
+def test_path_resolution_matches_java(monkeypatch, parent, child, expected, 
windows):
+    import pypaimon.utils.path as paths
+    monkeypatch.setattr(paths, '_WINDOWS', windows)
+    assert paths.resolve_path(parent, child) == expected
+
+
+def test_empty_path_is_rejected():
+    from pypaimon.utils.path import resolve_path
+    with pytest.raises(ValueError, match='empty string'):
+        resolve_path('/warehouse/t', '')
+
+
[email protected]('stored', [None, 'data'])
[email protected]('requested', [None, 'data', 'other'])
+def test_data_directory_cannot_change_on_table_copy(tmp_path, stored, 
requested):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    options = {} if stored is None else {'data-file.path-directory': stored}
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(pa.schema([('id', 
pa.int32())]), options=options), False)
+    table = catalog.get_table('db.t')
+    for copy in (table.copy, table.copy_without_time_travel):
+        if stored == requested:
+            assert copy({'data-file.path-directory': 
requested}).options.data_file_path_directory() == stored
+        else:
+            with pytest.raises(ValueError, match='immutable option'):
+                copy({'data-file.path-directory': requested})
+
+
[email protected]('parent,child,expected', [
+    ('relative', '../b:c', './b:c'),
+    ('relative', '../a:bb', './a:bb'),
+    ('relative', '../b:c/x', './b:c/x'),
+    ('warehouse/t', 'data/../../../b:c', './b:c'),
+    ('.', 'a/../b:c', './b:c'),
+    ('s3://bucket', '.', 's3://bucket'),
+    (r'C:\warehouse\table', '../data', 'C:/warehouse/data'),
+    (r'C:\warehouse\table', r'..\data', 'C:/warehouse/data'),
+    ('C:/warehouse/table', '/data', '/data'),
+    ('C:/warehouse/table', '../..', 'C:/'),
+    ('file:/C:/warehouse/table', '../..', 'file:/C:/'),
+    (r'\\server\share\table', 'data', '//server/share/table/data'),
+    ('s3://bucket/table', r'\\other\share', 's3://other/share'),
+    ('file:/C:/warehouse/table', r'..\data', 'file:/C:/warehouse/data'),
+    ('s3://bucket/t', r'data\literal', 's3://bucket/t/data/literal'),
+])
+def test_windows_paths_match_java(monkeypatch, parent, child, expected):
+    import pypaimon.utils.path as paths
+    monkeypatch.setattr(paths, '_WINDOWS', True)
+    assert paths.resolve_path(parent, child) == expected
+
+
[email protected]('windows,path,expected', [
+    (False, 'file:/tmp/data%2Fwith space?#part', '/tmp/data%2Fwith 
space?#part'),
+    (False, 'file:///tmp/data%20', '/tmp/data%20'),
+    (False, 'file://localhost/tmp/data%20', '/tmp/data%20'),
+    (False, 'file://host/share/data%20', '//host/share/data%20'),
+    (True, 'file:/C:/data%20', 'C:/data%20'),
+    (True, 'file://C:/data%20', 'C:/data%20'),
+    (True, 'file://host/share/data%20', '//host/share/data%20'),
+    (False, '/tmp/data%2F?#', '/tmp/data%2F?#'),
+    (False, 's3://bucket/data%2F?#', 's3://bucket/data%2F?#'),
+    (False, 'file:/tmp/plain', 'file:/tmp/plain'),
+])
+def test_file_io_paths_keep_literal_characters(monkeypatch, windows, path, 
expected):
+    import pypaimon.utils.path as paths
+    monkeypatch.setattr(paths, '_WINDOWS', windows)
+    assert paths.to_file_io_path(path) == expected
diff --git a/paimon-python/pypaimon/tests/deletion_vector_path_test.py 
b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
index f6d4673004..bfa2100448 100644
--- a/paimon-python/pypaimon/tests/deletion_vector_path_test.py
+++ b/paimon-python/pypaimon/tests/deletion_vector_path_test.py
@@ -128,7 +128,8 @@ def 
test_delete_paths_preserve_repeated_deletes_and_historical_reads(tmp_path, p
         else:
             expected = Path(table.table_path) / 'index' / file.file_name
         assert expected.is_file()
-        assert file.external_path == ('file://' + str(expected) if 'external' 
in layout else None)
+        assert file.external_path == (('file:' if layout == 'bucket-external' 
else 'file://')
+                                      + str(expected) if 'external' in layout 
else None)
         # Obsolete locations with the same name must never shadow canonical or
         # explicit paths. Invalid bytes make a wrong-path read fail observably.
         if layout != 'table':
diff --git a/paimon-python/pypaimon/tests/external_paths_test.py 
b/paimon-python/pypaimon/tests/external_paths_test.py
index aab4fa38a5..c249ba4db8 100644
--- a/paimon-python/pypaimon/tests/external_paths_test.py
+++ b/paimon-python/pypaimon/tests/external_paths_test.py
@@ -579,7 +579,7 @@ class ExternalPathsIntegrationTest(unittest.TestCase):
             for file_meta in commit_msg.new_files:
                 # External path should be set
                 self.assertIsNotNone(file_meta.external_path)
-                self.assertTrue(file_meta.external_path.startswith("file://"))
+                self.assertTrue(file_meta.external_path.startswith("file:"))
                 self.assertIn(self.external_dir, file_meta.external_path)
 
         table_commit.commit(commit_messages)
diff --git a/paimon-python/pypaimon/tests/native_data_directory_test.py 
b/paimon-python/pypaimon/tests/native_data_directory_test.py
new file mode 100644
index 0000000000..f24fb0dbd5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_data_directory_test.py
@@ -0,0 +1,226 @@
+# 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-compatible data directories across Python and Rust execution."""
+
+from contextlib import ExitStack
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.native_plan import native_plan
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.write.native_write import NativeTableWrite
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('value', 
pa.int32())])
+
+
+def _table(tmp_path, mode='append', directory='relative', native=True, 
partitioned=True, extra=None):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+    catalog.create_database('db', True)
+    directories = {'relative': 'data/nested', 'normalized': 
'data//discard/../nested/.',
+                   'absolute': str(tmp_path / 'relocated'), 'uri': (tmp_path / 
'relocated').as_uri(),
+                   'literal_uri': 'file:' + str(tmp_path / 'data%2Fwith 
space?#fragment')}
+    options = {'data-file.path-directory': directories[directory],
+               'write.native.enabled': str(native).lower()}
+    if mode == 'pk':
+        options.update({'bucket': '1', 'changelog-producer': 'input'})
+    if mode == 'evolution':
+        options.update({'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true',
+                        'target-file-row-num': '2',
+                        'deletion-vectors.enabled': 'true'})
+    options.update(extra or {})
+    partitions = ['pt'] if partitioned else []
+    keys = ['id'] + partitions if mode == 'pk' else []
+    catalog.create_table('db.t', Schema.from_pyarrow_schema(
+        _SCHEMA, primary_keys=keys, partition_keys=partitions, 
options=options), False)
+    return catalog.get_table('db.t')
+
+
+def _write(table, data, native, streaming=False, identifier=1):
+    builder = table.new_stream_write_builder() if streaming else 
table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        if native:
+            assert isinstance(writer, NativeTableWrite)
+        writer.write_arrow(data)
+        messages = writer.prepare_commit(identifier) if streaming else 
writer.prepare_commit()
+        factory = table.path_factory()
+        for message in messages:
+            expected = factory.bucket_path(tuple(message.partition), 
message.bucket, canonical_partition=True)
+            for file in message.new_files + message.changelog_files:
+                assert file.file_path.startswith(expected + '/')
+                assert table.file_io.exists(file.file_path)
+        if streaming:
+            commit.commit(messages, identifier)
+        else:
+            commit.commit(messages)
+        if native:
+            assert writer._python_writer is None
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _read(table, native_scan, native_read, streaming=False, snapshot=None):
+    options = {'read.native.enabled': str(native_read).lower(),
+               'scan.native-plan.enabled': str(native_scan).lower()}
+    if snapshot is not None:
+        options['scan.snapshot-id'] = str(snapshot)
+    table = table.copy(options)
+    builder = table.new_read_builder()
+    plan = native_plan(table) if native_scan else builder.new_scan().plan()
+    read = builder.new_read()
+    with ExitStack() as stack:
+        if native_read:
+            stack.enter_context(patch.object(read, '_create_split_read',
+                                             
side_effect=AssertionError('Python read fallback')))
+        if streaming:
+            reader = read.to_arrow_batch_reader(plan.splits())
+            try:
+                result = reader.read_all()
+            finally:
+                reader.close()
+        else:
+            result = read.to_arrow(plan.splits())
+    return result.sort_by('id').to_pylist()
+
+
[email protected]('directory', [
+    'relative', 'normalized', 'absolute', 'uri',
+    pytest.param('literal_uri', marks=pytest.mark.skipif(os.name == 'nt', 
reason='POSIX file names'))])
[email protected]('mode', ['append', 'pk', 'evolution'])
[email protected]('native', [False, True])
[email protected]('partitioned', [False, True])
+def test_data_directory_interoperability(tmp_path, directory, mode, native, 
partitioned):
+    table = _table(tmp_path, mode, directory, native, partitioned)
+    data = pa.table({'id': [1, 2, 3], 'pt': ['a', 'a', 'b'], 'value': [10, 20, 
30]}, schema=_SCHEMA)
+    _write(table, data, native)
+    _write(table, pa.table({'id': [4], 'pt': ['b'], 'value': [40]}, 
schema=_SCHEMA), native,
+           streaming=True, identifier=2)
+    if directory == 'literal_uri':
+        assert (tmp_path / 'data%2Fwith space?#fragment').is_dir()
+        assert not (tmp_path / 'data').exists()
+    expected = data.to_pylist() + [{'id': 4, 'pt': 'b', 'value': 40}]
+    for planner in (False, True):
+        for reader in (False, True):
+            assert _read(table, planner, reader, streaming=reader) == expected
+    assert (Path(table.table_path) / 'snapshot' / 'snapshot-2').is_file()
+    assert (Path(table.table_path) / 'schema' / 'schema-0').is_file()
+    assert not (Path(table.table_path) / 'bucket-0').exists()
+
+
[email protected](os.name == 'nt', reason='POSIX file names')
[email protected]('strategy', ['round-robin', 'entropy-inject', 
'weight-robin'])
+def test_literal_data_directory_with_external_paths(tmp_path, strategy):
+    table = _table(tmp_path, directory='literal_uri', native=False, extra={
+        'data-file.external-paths': ','.join((tmp_path / name).as_uri() for 
name in ['first', 'second']),
+        'data-file.external-paths.strategy': strategy,
+        'data-file.external-paths.weights': '1,2',
+    })
+    data = pa.table({'id': [1], 'pt': ['a'], 'value': [10]}, schema=_SCHEMA)
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(data)
+        messages = writer.prepare_commit()
+        assert all(file.external_path for message in messages for file in 
message.new_files)
+        commit.commit(messages)
+    finally:
+        writer.close()
+        commit.close()
+    assert (tmp_path / 'data%2Fwith space?#fragment').is_dir()
+    for planner in (False, True):
+        for reader in (False, True):
+            assert _read(table, planner, reader) == data.to_pylist()
+
+
[email protected]('directory', [
+    'relative', 'normalized', 'absolute', 'uri',
+    pytest.param('literal_uri', marks=pytest.mark.skipif(os.name == 'nt', 
reason='POSIX file names'))])
[email protected]('native', [False, True])
[email protected]('bucket_indexes', [False, True])
+def test_data_directory_updates_deletes_history_and_abort(tmp_path, directory, 
native, bucket_indexes):
+    table = _table(tmp_path, 'evolution', directory, native, extra={
+        'index-file-in-data-file-dir': str(bucket_indexes).lower()})
+    _write(table, pa.table({'id': [1, 2, 3], 'pt': ['a'] * 3, 'value': [10, 
20, 30]}, schema=_SCHEMA), native)
+    builder = table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(TableUpsertByKey, 
'_upsert_partition',
+                                             
side_effect=AssertionError('Python upsert fallback')))
+        messages = update.upsert_by_arrow_with_key(
+            pa.table({'id': [1, 4], 'pt': ['a', 'b'], 'value': [11, 40]}, 
schema=_SCHEMA), ['id'])
+    commit = builder.new_commit()
+    try:
+        commit.commit(messages)
+    finally:
+        commit.close()
+    for row_id, expected in [(0, [20, 30, 40]), (1, [30, 40])]:
+        messages = builder.new_update().delete_by_row_id([row_id])
+        commit = builder.new_commit()
+        try:
+            commit.commit(messages)
+        finally:
+            commit.close()
+        for planner in (False, True):
+            for reader in (False, True):
+                assert sorted(row['value'] for row in _read(table, planner, 
reader)) == expected
+    messages = builder.new_update().delete_by_row_id([2])
+    staged = {entry.index_file.file_name for msg in messages for entry in 
msg.index_adds}
+    assert staged
+    before = {p for p in tmp_path.rglob('index-*') if p.name in staged}
+    assert before
+    commit = builder.new_commit()
+    try:
+        commit.abort(messages)
+    finally:
+        commit.close()
+    assert not any(path.exists() for path in before)
+    assert [row['value'] for row in _read(table, True, True, snapshot=1)] == 
[10, 20, 30]
+    assert [row['value'] for row in _read(table, True, True)] == [30, 40]
+
+
[email protected]('directory', ['relative', 'absolute', 'uri'])
+def test_data_directory_blob_native_read(tmp_path, directory):
+    table = _table(tmp_path, 'evolution', directory, False)
+    catalog = table.catalog_environment.catalog_loader.load()
+    catalog.drop_table('db.t')
+    fields = [DataField(0, 'id', AtomicType('INT')), DataField(1, 'payload', 
AtomicType('BLOB'))]
+    catalog.create_table('db.t', Schema(fields=fields, 
options=table.table_schema.options), False)
+    table = catalog.get_table('db.t')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.table({'id': pa.array([1, 2, 3], pa.int32()),
+                                    'payload': pa.array([b'hello', None, b''], 
pa.large_binary())}))
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    for planner in (False, True):
+        for reader in (False, True):
+            assert _read(table, planner, reader) == [
+                {'id': 1, 'payload': b'hello'}, {'id': 2, 'payload': None}, 
{'id': 3, 'payload': b''}]
diff --git a/paimon-python/pypaimon/tests/native_write_test.py 
b/paimon-python/pypaimon/tests/native_write_test.py
index ba853082c0..bfe8b638b4 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -130,12 +130,19 @@ def 
test_batch_native_write_commits_through_both_committers(
 
 
 @requires_native
-def test_escaped_partition_file_path_and_abort(tmp_path, native_rest_catalog):
[email protected]('directory', [None, 'relative', 'absolute', 'uri'])
+def test_escaped_partition_file_path_and_abort(tmp_path, native_rest_catalog, 
directory):
     catalog = native_rest_catalog
+    options = {'file.format': 'parquet', 'write.native.enabled': 'true',
+               'commit.native.enabled': 'true'}
+    if directory is not None:
+        options['data-file.path-directory'] = {
+            'relative': 'data/nested', 'absolute': str(tmp_path / 'relocated'),
+            'uri': (tmp_path / 'relocated').as_uri(),
+        }[directory]
     catalog.create_table('default.t', Schema.from_pyarrow_schema(
         pa.schema([('id', pa.int64()), ('pt', pa.string())]),
-        options={'file.format': 'parquet', 'write.native.enabled': 'true',
-                 'commit.native.enabled': 'true'}, partition_keys=['pt']), 
False)
+        options=options, partition_keys=['pt']), False)
     table = catalog.get_table('default.t')
     builder = table.new_batch_write_builder()
     writer = builder.new_write()
diff --git a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py 
b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
index 68d26e86e8..8833b1fc88 100644
--- a/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
+++ b/paimon-python/pypaimon/tests/table_upsert_by_key_test.py
@@ -22,6 +22,7 @@ from unittest import mock
 
 import pyarrow as pa
 import pyarrow.parquet as pq
+import pytest
 
 from pypaimon.read.table_read import TableRead
 from pypaimon.table.special_fields import SpecialFields
@@ -140,6 +141,7 @@ class _TableUpsertByKeyTestBase(DataEvolutionTestBase):
                 _RowIdUpdateFileWriter(table, (), ['id'])
             output_stream.assert_not_called()
 
+    @pytest.mark.python_write
     @mock.patch.object(_RowIdUpdateFileWriter, '_ROW_GROUP_MAX_ROWS', 2)
     def test_partial_upsert_streams_original_file_group(self):
         schema = pa.schema([
diff --git a/paimon-python/pypaimon/utils/file_store_path_factory.py 
b/paimon-python/pypaimon/utils/file_store_path_factory.py
index 35594c2963..147bf3d70c 100644
--- a/paimon-python/pypaimon/utils/file_store_path_factory.py
+++ b/paimon-python/pypaimon/utils/file_store_path_factory.py
@@ -22,6 +22,7 @@ from typing import List, Optional, Tuple
 
 from pypaimon.casting.row_to_string import cast_value_to_string, 
_format_timestamp, _is_unsupported
 from pypaimon.common.external_path_provider import ExternalPathProvider
+from pypaimon.utils.path import resolve_path, to_file_io_path
 from pypaimon.schema.data_types import DataType
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.table.row.generic_row import _is_ltz_type, _normalize_ltz, 
_parse_type_precision_scale
@@ -47,7 +48,7 @@ def canonical_data_file_path(table, partition, bucket, 
file_name):
     bucket_path = table.path_factory().bucket_path(
         tuple(partition), bucket, canonical_partition=True)
     path = f"{bucket_path.rstrip('/')}/{file_name}"
-    root = table.table_path.rstrip('/')
+    root = resolve_path(table.path_factory().data_file_path(), '.').rstrip('/')
     if (root.startswith('file:') and path.startswith(root + '/')
             and '%' in path[len(root) + 1:]):
         from pypaimon.filesystem.local_file_io import LocalFileIO
@@ -133,6 +134,8 @@ class FileStorePathFactory:
         self.changelog_file_prefix = changelog_file_prefix
         self.file_suffix_include_compression = file_suffix_include_compression
         self.file_compression = file_compression
+        if data_file_path_directory == '':
+            raise ValueError('data-file.path-directory must not be empty')
         self.data_file_path_directory = data_file_path_directory
         self.external_paths = external_paths or []
         self.external_path_strategy = external_path_strategy
@@ -159,9 +162,8 @@ class FileStorePathFactory:
         return f"{self._root}/{self.STATISTICS_PATH}"
 
     def data_file_path(self) -> str:
-        if self.data_file_path_directory:
-            return f"{self._root}/{self.data_file_path_directory}"
-        return self._root
+        path = resolve_path(self._root, self.data_file_path_directory)
+        return to_file_io_path(path) if self.data_file_path_directory is not 
None else path
 
     def relative_bucket_path(self, partition: Tuple, bucket: int, 
canonical_partition: bool = False) -> str:
         if canonical_partition and partition:
@@ -228,14 +230,19 @@ class FileStorePathFactory:
                 relative_parts = partition_parts + relative_parts
 
         # Add data file path directory if specified
+        relative = "/".join(relative_parts)
+        # Legacy Python timestamp partitions contain literal colons. Prefix a
+        # relative path so URI resolution cannot mistake the first field for a 
scheme.
+        if ':' in relative.split('/', 1)[0]:
+            relative = './' + relative
         if self.data_file_path_directory:
-            relative_parts = [self.data_file_path_directory] + relative_parts
-
-        return "/".join(relative_parts)
+            return resolve_path(self.data_file_path_directory, relative)
+        return relative
 
     def bucket_path(self, partition: Tuple, bucket: int, canonical_partition: 
bool = False) -> str:
         relative_path = self.relative_bucket_path(partition, bucket, 
canonical_partition)
-        return f"{self._root}/{relative_path}"
+        path = resolve_path(self._root, relative_path)
+        return to_file_io_path(path) if self.data_file_path_directory is not 
None else path
 
     def create_external_path_provider(
         self, partition: Tuple, bucket: int
diff --git a/paimon-python/pypaimon/utils/path.py 
b/paimon-python/pypaimon/utils/path.py
new file mode 100644
index 0000000000..6987353bb0
--- /dev/null
+++ b/paimon-python/pypaimon/utils/path.py
@@ -0,0 +1,117 @@
+# 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.
+
+"""Resolve storage paths using Paimon's Java Path semantics.
+
+Path text contains literal percent signs, spaces, query and fragment 
characters;
+URL joining would incorrectly interpret or escape these characters.
+"""
+
+
+import os
+import re
+
+
+_WINDOWS = os.name == 'nt'
+
+
+def _has_drive(path):
+    return bool(re.match(r'^/?[A-Za-z]:', path))
+
+
+def _normalize(path):
+    components = []
+    for component in path.split('/'):
+        if component in ('', '.'):
+            continue
+        if component == '..' and components and components[-1] != '..':
+            components.pop()
+        else:
+            components.append(component)
+    normalized = ('/' if path.startswith('/') else '') + '/'.join(components)
+    if (_WINDOWS and normalized.startswith('/') and _has_drive(normalized)
+            and len(normalized) == 3 and path != normalized):
+        normalized += '/'
+    elif not normalized.startswith('/') and ':' in normalized.split('/')[0]:
+        normalized = './' + normalized
+    return normalized
+
+
+def _parts(path):
+    if _WINDOWS and _has_drive(path) and not path.startswith('/'):
+        path = '/' + path
+    colon = path.find(':')
+    scheme_end = colon + 1 if colon >= 0 and '/' not in path[:colon] else 0
+    scheme, authority = path[:scheme_end], ''
+    if path[scheme_end:].startswith('//') and len(path) - scheme_end > 2:
+        slash = path.find('/', scheme_end + 2)
+        end = len(path) if slash < 0 else slash
+        if end > scheme_end + 2:
+            authority = path[scheme_end:end]
+        path = path[end:]
+    else:
+        path = path[scheme_end:]
+    path = re.sub(r'/+', '/', path)
+    if _WINDOWS and (_has_drive(path) or scheme in ('', 'file:')):
+        path = path.replace('\\', '/')
+    # Java URI recognizes a UNC authority after Windows separator conversion.
+    if not authority and path.startswith('//') and len(path) > 2:
+        end = path.find('/', 2)
+        end = len(path) if end < 0 else end
+        authority, path = path[:end], path[end:]
+    return scheme, authority, _normalize(path)
+
+
+def resolve_path(parent, child):
+    """Resolve child against a directory, retaining its scheme and 
authority."""
+    if child is None:
+        return parent.rstrip('/') or '/'
+    if child == '':
+        raise ValueError('Can not create a Path from an empty string')
+    parent_scheme, parent_authority, parent_path = _parts(parent)
+    child_scheme, child_authority, child_path = _parts(child)
+    if child_scheme:
+        scheme, authority, path = child_scheme, child_authority, child_path
+    elif child_authority:
+        scheme, authority, path = parent_scheme, child_authority, child_path
+    elif child_path.startswith('/'):
+        scheme, authority, path = parent_scheme, parent_authority, child_path
+    else:
+        scheme, authority = parent_scheme, parent_authority
+        path = (parent_path.rstrip('/') + '/' + child_path
+                if parent_path or (child_path and (scheme or authority)) else 
child_path)
+    path = _normalize(path)
+    if not scheme and not authority:
+        if _WINDOWS and _has_drive(path):
+            path = path.lstrip('/')
+        elif not path.startswith('/') and ':' in path.split('/')[0]:
+            path = './' + path
+    return scheme + authority + path
+
+
+def to_file_io_path(path):
+    """Keep literal Java file-path characters from being decoded as a URL."""
+    if not path.startswith('file:') or not any(char in path for char in '%?#'):
+        return path
+    _, authority, local_path = _parts(path)
+    if authority and authority != '//localhost':
+        if not (_WINDOWS and authority.endswith(':')):
+            return authority + local_path
+        local_path = authority[1:] + local_path
+    if _WINDOWS and _has_drive(local_path):
+        local_path = local_path.lstrip('/')
+    return local_path
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 8ecab84802..3bd97ea32e 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -37,7 +37,6 @@ def _native_row_id_table(table):
             or not table.options.native_write_enabled()
             or not table.options.data_evolution_enabled()
             or not table.options.row_tracking_enabled()
-            or table.options.data_file_path_directory() is not None
             or not _RowIdUpdateFileWriter.supports_table(table)
             or any(table.options.options.contains_key(key) for key in 
SCAN_KEYS)
             or not native_write_available()):
diff --git a/paimon-python/pypaimon/write/table_commit.py 
b/paimon-python/pypaimon/write/table_commit.py
index 41472bf358..ef2595d93c 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -117,11 +117,6 @@ class TableCommit:
         if (not self.table.options.native_commit_enabled()
                 or self._commit_callbacks):
             return None
-        # data-file.path-directory keeps the whole pipeline on the Python
-        # path (which resolves the relocated directory); see the matching
-        # write / read / plan fallbacks.
-        if self.table.options.data_file_path_directory() is not None:
-            return None
         try:
             from pypaimon.write.native_commit import (
                 create_native_commit, native_messages_supported,
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index 81e3717ed1..38589a9215 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -683,7 +683,6 @@ class TableUpdate:
         splits = scan.plan_for_write().splits()
         if (splits
                 and self.table.options.native_write_enabled()
-                and self.table.options.data_file_path_directory() is None
                 and not any(isinstance(split, QueryAuthSplit)
                             for split in splits)):
             try:
diff --git a/paimon-python/pypaimon/write/write_builder.py 
b/paimon-python/pypaimon/write/write_builder.py
index bfe76659f9..bd518d826d 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -66,12 +66,6 @@ class WriteBuilder(ABC):
         check_sequence_field_supported(self.table)
         if not self.table.options.native_write_enabled():
             return None
-        # data-file.path-directory relocates data files under a sub-directory
-        # that the native writer does not honor (it writes at the bucket
-        # root). Use the Python writer, which resolves the directory, so
-        # write / read / plan / commit stay consistent for this option.
-        if self.table.options.data_file_path_directory() is not None:
-            return None
         try:
             from pypaimon.write.native_write import create_native_write
             return create_native_write(self.table, self.commit_user,

Reply via email to