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 a52df860f8 [python] Fix mounted BLOB reads bypassing table FileIO 
(#10621)
a52df860f8 is described below

commit a52df860f87dd74a220b8aa18d8b7db460147155
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Oct 17 22:09:53 2026 +0800

    [python] Fix mounted BLOB reads bypassing table FileIO (#10621)
---
 .../pypaimon/filesystem/oss_blob_presigner.py      | 24 ++-----
 paimon-python/pypaimon/filesystem/uri_utils.py     | 46 ++++++++++++
 paimon-python/pypaimon/multimodal/blob_store.py    | 16 +++++
 .../pypaimon/tests/multimodal_table_test.py        | 83 +++++++++++++++++++++-
 4 files changed, 149 insertions(+), 20 deletions(-)

diff --git a/paimon-python/pypaimon/filesystem/oss_blob_presigner.py 
b/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
index 3f02e3d6b6..a5231b1258 100644
--- a/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
+++ b/paimon-python/pypaimon/filesystem/oss_blob_presigner.py
@@ -20,10 +20,11 @@
 import hashlib
 import logging
 import posixpath
-import re
 from datetime import timedelta
 from urllib.parse import unquote, urlparse, urlsplit
 
+from pypaimon.filesystem.uri_utils import normalize_uri_path, 
table_relative_path
+
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -104,24 +105,9 @@ def _validity_seconds(validity, has_security_token=False) 
-> int:
 def _validate_table_root(table_root, descriptor):
     root = urlparse(str(table_root))
     blob = urlparse(descriptor.uri)
-    root_path = _normalize_path(root.path)
-    blob_path = _normalize_path(blob.path)
-    if ((root.scheme or '').lower() != (blob.scheme or '').lower()
-            or root.netloc != blob.netloc):
-        raise ValueError(
-            "Blob descriptor URI must use the same scheme and authority "
-            "as table root.")
-    child_prefix = root_path if root_path.endswith('/') else root_path + '/'
-    if not blob_path.startswith(child_prefix):
-        raise ValueError("Blob descriptor URI must be under table root.")
-    return blob._replace(path=blob_path)
-
-
-def _normalize_path(path):
-    normalized = posixpath.normpath(re.sub('/+', '/', path or '/'))
-    if path.startswith('/') and not normalized.startswith('/'):
-        normalized = '/' + normalized
-    return normalized
+    relative_path = table_relative_path(table_root, descriptor.uri)
+    return blob._replace(
+        path=posixpath.join(normalize_uri_path(root.path), relative_path))
 
 
 def _head_object_if_exists(bucket, key):
diff --git a/paimon-python/pypaimon/filesystem/uri_utils.py 
b/paimon-python/pypaimon/filesystem/uri_utils.py
new file mode 100644
index 0000000000..03ba7c64ec
--- /dev/null
+++ b/paimon-python/pypaimon/filesystem/uri_utils.py
@@ -0,0 +1,46 @@
+# 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.
+
+"""URI path containment shared by managed BLOB readers and presigning."""
+
+import posixpath
+import re
+from urllib.parse import urlparse
+
+
+def normalize_uri_path(path):
+    normalized = posixpath.normpath(re.sub('/+', '/', path or '/'))
+    if path.startswith('/') and not normalized.startswith('/'):
+        normalized = '/' + normalized
+    return normalized
+
+
+def table_relative_path(table_root, uri):
+    """Return a normalized child path, rejecting URIs outside the table 
root."""
+    root = urlparse(str(table_root))
+    child = urlparse(str(uri))
+    if ((root.scheme or '').lower() != (child.scheme or '').lower()
+            or root.netloc != child.netloc):
+        raise ValueError(
+            "Blob descriptor URI must use the same scheme and authority "
+            "as table root.")
+
+    root_path = normalize_uri_path(root.path)
+    child_path = normalize_uri_path(child.path)
+    child_prefix = root_path if root_path.endswith('/') else root_path + '/'
+    if not child_path.startswith(child_prefix):
+        raise ValueError("Blob descriptor URI must be under table root.")
+    return child_path[len(child_prefix):]
diff --git a/paimon-python/pypaimon/multimodal/blob_store.py 
b/paimon-python/pypaimon/multimodal/blob_store.py
index 061402ba87..2d92b3ee92 100644
--- a/paimon-python/pypaimon/multimodal/blob_store.py
+++ b/paimon-python/pypaimon/multimodal/blob_store.py
@@ -20,9 +20,11 @@ import re
 from dataclasses import dataclass
 from datetime import timedelta
 from typing import BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence
+from urllib.parse import urlparse
 
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.filesystem.uri_utils import table_relative_path
 from pypaimon.schema.data_types import is_blob_type
 from pypaimon.table.row.blob import Blob, BlobData, BlobDescriptor
 from pypaimon.table.row.generic_row import GenericRow
@@ -70,6 +72,20 @@ class BlobObject:
 
     def open(self) -> BinaryIO:
         descriptor = _descriptor_for_range(self.descriptor, self.range_header)
+        if self.table_root is not None:
+            parsed = urlparse(descriptor.uri)
+            if not (parsed.params or parsed.query or parsed.fragment):
+                try:
+                    relative_path = table_relative_path(
+                        self.table_root, descriptor.uri)
+                except ValueError:
+                    pass
+                else:
+                    # Use the table root's exact spelling for FUSE prefix 
mapping.
+                    managed_uri = self.table_root.rstrip("/") + "/" + 
relative_path
+                    return Blob.from_file(
+                        self.file_io, managed_uri, descriptor.offset,
+                        descriptor.length).new_input_stream()
         uri_reader = self.file_io.uri_reader_factory.create(descriptor.uri)
         return Blob.from_descriptor(uri_reader, descriptor).new_input_stream()
 
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py 
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 629fffbca0..7154d27de7 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -1142,11 +1142,92 @@ class MultimodalTableTest(unittest.TestCase):
         self.assertEqual(len(data), result.size)
         self.assertEqual("videos/2", more[0].key)
         obj = store.get_object("videos/1")
-        self.assertEqual(data, obj.read())
+        with patch.object(obj.file_io, "new_input_stream", 
side_effect=AssertionError(
+                "external object must use its URI reader")):
+            self.assertEqual(data, obj.read())
         self.assertEqual("video/mp4", obj.columns["media_type"])
         self.assertEqual(external_path, 
store.head_object("videos/1").descriptor.uri)
         self.assertEqual(data, store.get_object("videos/2").read())
 
+    def test_blob_store_managed_object_reads_through_fuse_file_io(self):
+        from pypaimon.common.file_io import FileIO
+        from pypaimon.filesystem.local_file_io import FuseLocalFileIO
+        from pypaimon.table.row.blob import BlobDescriptor
+
+        table = self.conn.create_table(
+            "mounted_objects",
+            schema=_schema({"key": pa.string(), "payload": pa.large_binary()}),
+            options=_PARQUET_OPTIONS,
+        )
+        store = table.blobs(column="payload")
+        store.put_object("example.hdf5", body=b"hdf5-payload")
+        obj = store.get_object("example.hdf5")
+
+        remote_root = "oss://test-bucket/table"
+        remote_uri = remote_root + obj.descriptor.uri[len(obj.table_root):]
+        mounted = replace(
+            obj,
+            descriptor=BlobDescriptor(
+                remote_uri, obj.descriptor.offset, obj.descriptor.length),
+            file_io=FuseLocalFileIO(remote_root, obj.table_root),
+            table_root=remote_root,
+        )
+
+        with patch.object(FileIO, "get", side_effect=AssertionError(
+                "managed object must use the table FileIO")):
+            with mounted.open() as stream:
+                self.assertEqual(b"hdf5-payload", stream.read())
+            self.assertEqual(b"df5", replace(
+                mounted, range_header="bytes=1-3").read())
+
+        suffix = remote_uri[len(remote_root):]
+        for variant in (
+                "OSS://test-bucket/table" + suffix,
+                remote_root + "//./" + suffix.lstrip("/"),
+                remote_root + "/sub/../" + suffix.lstrip("/")):
+            with self.subTest(uri=variant), patch.object(
+                    mounted.file_io.uri_reader_factory, "create",
+                    side_effect=AssertionError("managed object must not use a 
URI reader")):
+                normalized = replace(
+                    mounted,
+                    descriptor=BlobDescriptor(
+                        variant, obj.descriptor.offset, obj.descriptor.length),
+                )
+                self.assertEqual(b"hdf5-payload", normalized.read())
+
+    def test_blob_store_external_uris_do_not_use_fuse_file_io(self):
+        from pypaimon.filesystem.local_file_io import FuseLocalFileIO
+        from pypaimon.multimodal.blob_store import BlobObject
+        from pypaimon.table.row.blob import BlobDescriptor
+
+        class ExternalReader:
+
+            def new_input_stream(self, uri):
+                return io.BytesIO(b"external")
+
+        root = "oss://test-bucket/table"
+        file_io = FuseLocalFileIO(root, self.temp_dir)
+        for uri in (
+                root + "/../other/payload.bin",
+                "oss://other-bucket/table/payload.bin",
+                "oss://test-bucket/table-sibling/payload.bin",
+                "s3://test-bucket/table/payload.bin",
+                root + "/payload.bin?token=external"):
+            with self.subTest(uri=uri), patch.object(
+                    file_io, "new_input_stream",
+                    side_effect=AssertionError("external object must not use 
table FileIO")), \
+                    patch.object(file_io.uri_reader_factory, "create",
+                                 return_value=ExternalReader()) as 
create_reader:
+                obj = BlobObject(
+                    key="external",
+                    descriptor=BlobDescriptor(uri, 0, len(b"external")),
+                    columns={},
+                    file_io=file_io,
+                    table_root=root,
+                )
+                self.assertEqual(b"external", obj.read())
+                create_reader.assert_called_once_with(uri)
+
     def test_blob_store_put_object_uri_streams_into_managed_blob(self):
         table = self.conn.create_table(
             "managed_only_objects",

Reply via email to