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 d7bae9ba15 [python] Add multimodal blob store API (#8432)
d7bae9ba15 is described below
commit d7bae9ba15743e4e2fe22c194118d72c24ec8164
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Jul 2 23:26:33 2026 +0800
[python] Add multimodal blob store API (#8432)
Adds an S3-like object facade for PyPaimon multimodal BLOB columns. The
API supports object-style writes, reads, listing, deletion, byte ranges,
table-column projection, and Paimon-native descriptor/reference storage.
---
docs/docs/pypaimon/multimodal-api.mdx | 161 ++++-
paimon-python/pypaimon/multimodal/__init__.py | 12 +
paimon-python/pypaimon/multimodal/blob_store.py | 646 +++++++++++++++++++++
paimon-python/pypaimon/multimodal/connection.py | 1 +
paimon-python/pypaimon/multimodal/table.py | 4 +
.../pypaimon/tests/multimodal_table_test.py | 253 ++++++++
6 files changed, 1071 insertions(+), 6 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index 2dca79feaf..b061aa36c0 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -34,12 +34,12 @@ a Paimon catalog plus a default database, and exposes a
compact table API for
scalar, text, vector, and blob data.
Creating a multimodal table enables `row-tracking.enabled`,
-`data-evolution.enabled`, and `deletion-vectors.enabled` by default. Global
-index scans use full fallback coverage through `global-index.search-mode` set
-to `FULL`. Data files use Vortex by default through `file.format = vortex`, and
-vector columns are stored in dedicated Vortex files through
-`vector.file.format` set to `vortex`. Install the Vortex extra when writing or
-reading default multimodal tables from Python:
+`data-evolution.enabled`, `deletion-vectors.enabled`, and
+`blob-as-descriptor` by default. Global index scans use full fallback coverage
+through `global-index.search-mode` set to `FULL`. Data files use Vortex by
+default through `file.format = vortex`, and vector columns are stored in
+dedicated Vortex files through `vector.file.format` set to `vortex`. Install
+the Vortex extra when writing or reading default multimodal tables from Python:
```shell
pip install pypaimon[vortex]
@@ -455,3 +455,152 @@ batch_neighbors = (
.to_list()
)
```
+
+## Blobs
+
+Use `blobs()` to work with a BLOB column through an S3-like object API. A blob
+store maps object keys to a table column such as `key`, `object_key`, or an
+explicit `key_column`.
+
+### Put Objects
+
+`put_object` writes one object. `put_objects` writes a batch of objects in one
+Paimon commit. Both methods upsert by key: existing matching rows are updated,
+and missing keys append new rows. If the same key appears more than once in a
+single `put_objects` batch, the last object wins. BlobStore does not enforce
+object-key uniqueness; that should be modeled with a table-level unique key
when
+available. The object body can be raw bytes, a binary file-like object, or a
+PyPaimon `Blob`. Use `columns` to set the non-key, non-BLOB table columns for
+the object row. For managed BLOB storage, use `head_object` or `get_object` to
+inspect the stored descriptor after the write.
+
+```python
+store = docs.blobs(column="image", key_column="key")
+
+store.put_object(
+ "images/cat.jpg",
+ body=open("cat.jpg", "rb"),
+ columns={"content_type": "image/jpeg"},
+)
+
+store.put_objects([
+ {
+ "key": "images/dog.jpg",
+ "body": open("dog.jpg", "rb"),
+ "columns": {"content_type": "image/jpeg"},
+ },
+ {
+ "key": "images/logo.png",
+ "body": b"...",
+ "columns": {"content_type": "image/png"},
+ },
+])
+```
+
+For Paimon-native reference storage, configure the BLOB column as a
+`blob-descriptor-field` and pass `uri`/`offset`/`length` or `descriptor` to
+`put_object` and `put_objects`. The table stores the external `BlobDescriptor`
+instead of copying the object into Paimon `.blob` files. Without
+`blob-descriptor-field`, descriptor-backed inputs are streamed into managed
+Paimon `.blob` files. External S3 URIs are read with the table's FileIO, so S3
+credentials such as `fs.s3.accessKeyId`, `fs.s3.accessKeySecret`,
+`fs.s3.securityToken`, `fs.s3.endpoint`, and `fs.s3.region` can be supplied in
+the options passed to `connect`. The same options are used for every external
+URI; there is no per-object credential override.
+
+```python
+store.put_objects([
+ {
+ "key": "videos/intro.mp4",
+ "uri": "s3://bucket/videos/intro.mp4",
+ "offset": 0,
+ "length": intro_content_length,
+ "columns": {"content_type": "video/mp4"},
+ },
+ {
+ "key": "videos/demo.mp4",
+ "uri": "s3://bucket/videos/demo.mp4",
+ "offset": 0,
+ "length": demo_content_length,
+ "columns": {"content_type": "video/mp4"},
+ },
+])
+
+store.put_object(
+ "videos/trailer.mp4",
+ uri="s3://bucket/videos/trailer.mp4",
+ offset=0,
+ length=trailer_content_length,
+ columns={"content_type": "video/mp4"},
+)
+```
+
+### Read Objects
+
+`get_object` reads an object by key. Its `range` parameter accepts a single
+S3-style byte range such as `bytes=0-1023`, `bytes=1024-`, or `bytes=-512`.
+`head_object` and `list_objects` return object information without opening the
+object stream. `get_object`, `head_object`, and `list_objects` expose non-key,
+non-BLOB table columns through `columns`. These columns are all returned by
+default. Pass `columns` to return only selected columns, or `[]` to skip them.
+`list_objects` requires a non-negative `limit`; `limit=0` returns an empty
+list.
+
+```python
+obj = store.get_object("images/cat.jpg", range="bytes=0-1023")
+with obj.open() as stream:
+ chunk = stream.read()
+
+info = store.head_object(
+ "images/cat.jpg",
+ columns=["content_type"],
+)
+content_type = info.columns["content_type"]
+
+objects = store.list_objects(prefix="images/", columns=[])
+for obj_info in objects:
+ print(obj_info.key, obj_info.size, obj_info.columns)
+```
+
+### Delete Objects
+
+`delete_object` deletes one object key. `delete_objects` deletes a batch of
keys
+in one Paimon commit. Missing keys are ignored, and repeated keys in the same
+request are folded before deletion. BlobStore does not enforce object-key
+uniqueness; if multiple table rows already match a key, deleting that key
removes
+all matching rows.
+
+```python
+store.delete_object("images/dog.jpg")
+
+store.delete_objects([
+ "images/old-1.jpg",
+ "images/old-2.jpg",
+])
+```
+
+### Update Columns
+
+Amazon S3 updates object metadata by copying the object to the same key with
+replacement metadata. BlobStore exposes the corresponding Paimon-native
+operation directly: `update_object_columns` and `update_objects_columns` update
+non-key, non-BLOB table columns without rewriting the blob data. Missing keys
+raise `NoSuchKey`.
+
+```python
+store.update_object_columns(
+ "images/cat.jpg",
+ {"content_type": "image/webp", "owner": "alice"},
+)
+
+store.update_objects_columns([
+ {
+ "key": "images/dog.jpg",
+ "columns": {"content_type": "image/webp"},
+ },
+ {
+ "key": "images/logo.png",
+ "columns": {"content_type": "image/svg+xml"},
+ },
+])
+```
diff --git a/paimon-python/pypaimon/multimodal/__init__.py
b/paimon-python/pypaimon/multimodal/__init__.py
index 2f957630d4..1b5060ec8f 100644
--- a/paimon-python/pypaimon/multimodal/__init__.py
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -17,6 +17,13 @@
"""High-level APIs for mutable multimodal Paimon tables."""
+from pypaimon.multimodal.blob_store import (
+ BlobObject,
+ BlobStore,
+ NoSuchKey,
+ ObjectInfo,
+ PutObjectResult,
+)
from pypaimon.multimodal.connection import MultimodalConnection, connect
from pypaimon.multimodal.table import (
MultimodalTable,
@@ -32,8 +39,13 @@ from pypaimon.table.data_evolution_merge_into import (
)
__all__ = [
+ "BlobObject",
+ "BlobStore",
"MultimodalConnection",
"MultimodalTable",
+ "NoSuchKey",
+ "ObjectInfo",
+ "PutObjectResult",
"TextRoute",
"VectorRoute",
"connect",
diff --git a/paimon-python/pypaimon/multimodal/blob_store.py
b/paimon-python/pypaimon/multimodal/blob_store.py
new file mode 100644
index 0000000000..e4f4b654b5
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/blob_store.py
@@ -0,0 +1,646 @@
+# 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.
+
+import io
+import re
+from dataclasses import dataclass
+from typing import BinaryIO, Dict, Iterable, List, Mapping, Optional, Sequence
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
+from pypaimon.table.row.blob import Blob, BlobData, BlobDescriptor
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+
+_RANGE_PATTERN = re.compile(r"^bytes=(\d*)-(\d*)$")
+
+
+class NoSuchKey(KeyError):
+ """Raised when a blob key does not exist in a multimodal blob store."""
+
+
+@dataclass(frozen=True)
+class PutObjectResult:
+ key: object
+ size: Optional[int]
+
+
+@dataclass(frozen=True)
+class ObjectInfo:
+ key: object
+ size: Optional[int]
+ descriptor: Optional[BlobDescriptor]
+ columns: Dict[str, object]
+
+
+@dataclass(frozen=True)
+class BlobObject:
+ key: object
+ descriptor: BlobDescriptor
+ columns: Dict[str, object]
+ file_io: object
+ range_header: Optional[str] = None
+
+ @property
+ def size(self) -> int:
+ return self.descriptor.length
+
+ @property
+ def content_length(self) -> int:
+ descriptor = _descriptor_for_range(self.descriptor, self.range_header)
+ return descriptor.length
+
+ def open(self) -> BinaryIO:
+ descriptor = _descriptor_for_range(self.descriptor, self.range_header)
+ uri_reader = self.file_io.uri_reader_factory.create(descriptor.uri)
+ return Blob.from_descriptor(uri_reader, descriptor).new_input_stream()
+
+ def read(self) -> bytes:
+ with self.open() as stream:
+ return stream.read()
+
+
+class BlobStore:
+ """S3-like object facade over a Paimon multimodal table BLOB column."""
+
+ def __init__(
+ self,
+ table,
+ column: Optional[str] = None,
+ key_column: Optional[str] = None):
+ self._table = table
+ self._raw_table = table.raw_table
+ self.column = column or self._infer_blob_column()
+ self.key_column = key_column or self._infer_key_column()
+ self._validate_columns()
+
+ def put_object(
+ self,
+ key,
+ body=None,
+ *,
+ uri: Optional[str] = None,
+ offset: int = 0,
+ length: int = -1,
+ descriptor: Optional[BlobDescriptor] = None,
+ columns: Optional[Mapping[str, object]] = None) -> PutObjectResult:
+ return self.put_objects([
+ {
+ "key": key,
+ "body": body,
+ "uri": uri,
+ "offset": offset,
+ "length": length,
+ "descriptor": descriptor,
+ "columns": dict(columns or {}),
+ }
+ ])[0]
+
+ def put_objects(self, objects: Iterable[Mapping[str, object]]) ->
List[PutObjectResult]:
+ rows = []
+ for obj in objects:
+ key = _require_key(obj)
+ row = dict(obj.get("columns") or {})
+ row[self.key_column] = key
+ row[self.column] = self._object_to_blob(obj)
+ rows.append(row)
+ if not rows:
+ return []
+ return self._put_rows(self._deduplicate_rows_last_write_wins(rows))
+
+ def update_object_columns(
+ self,
+ key,
+ columns: Mapping[str, object]) -> ObjectInfo:
+ return self.update_objects_columns([
+ {"key": key, "columns": columns}
+ ])[0]
+
+ def update_objects_columns(
+ self,
+ objects: Iterable[Mapping[str, object]]) -> List[ObjectInfo]:
+ updates = []
+ keys = []
+ for obj in objects:
+ key = _require_key(obj)
+ columns = dict(obj.get("columns") or {})
+ if not columns:
+ raise ValueError("columns must not be empty.")
+ self._selected_update_columns(columns.keys())
+ updates.append((key, columns))
+ keys.append(key)
+ if not updates:
+ return []
+ self._validate_unique_keys(keys)
+ self._commit_column_updates(updates)
+ return [
+ self.head_object(key)
+ for key in keys
+ ]
+
+ def get_object(
+ self,
+ key,
+ *,
+ range: Optional[str] = None,
+ columns: Optional[Sequence[str]] = None) -> BlobObject:
+ info = self.head_object(key, columns=columns)
+ if info.descriptor is None:
+ raise NoSuchKey(key)
+ _descriptor_for_range(info.descriptor, range)
+ return BlobObject(
+ key=info.key,
+ descriptor=info.descriptor,
+ columns=info.columns,
+ file_io=self._raw_table.file_io,
+ range_header=range,
+ )
+
+ def head_object(
+ self,
+ key,
+ *,
+ columns: Optional[Sequence[str]] = None) -> ObjectInfo:
+ rows = self._read_rows(
+ keys=[key],
+ include_blob=True,
+ columns=columns,
+ )
+ if not rows:
+ raise NoSuchKey(key)
+ if len(rows) > 1:
+ raise ValueError("Multiple rows found for blob key %r." % key)
+ return self._row_to_info(rows[0])
+
+ def list_objects(
+ self,
+ *,
+ prefix: Optional[str] = None,
+ limit: Optional[int] = None,
+ columns: Optional[Sequence[str]] = None) -> List[ObjectInfo]:
+ if limit is not None:
+ if limit < 0:
+ raise ValueError("limit must be greater than or equal to 0.")
+ if limit == 0:
+ return []
+ rows = self._read_rows(
+ include_blob=True,
+ columns=columns,
+ )
+ objects = []
+ for row in rows:
+ key = row[self.key_column]
+ if prefix is not None and not str(key).startswith(prefix):
+ continue
+ objects.append(self._row_to_info(row))
+ if limit is not None and len(objects) >= limit:
+ break
+ return objects
+
+ def delete_object(self, key) -> None:
+ self.delete_objects([key])
+
+ def delete_objects(self, keys: Sequence[object]) -> None:
+ if not keys:
+ return
+ keys = self._deduplicate_keys(keys)
+ write_builder = self._raw_table.new_batch_write_builder()
+ table_update = write_builder.new_update()
+ table_commit = write_builder.new_commit()
+ try:
+ messages = table_update.delete_by_predicate(
+ self._keys_predicate(keys))
+ table_commit.commit(messages)
+ finally:
+ table_commit.close()
+
+ def _put_rows(self, rows: List[Mapping[str, object]]) ->
List[PutObjectResult]:
+ self._commit_upsert_once(rows)
+ return [
+ self._put_result_from_row(row)
+ for row in rows
+ ]
+
+ def _deduplicate_rows_last_write_wins(
+ self,
+ rows: List[Mapping[str, object]]) -> List[Mapping[str, object]]:
+ key_to_last_index = {}
+ for index, row in enumerate(rows):
+ key_to_last_index[row[self.key_column]] = index
+ if len(key_to_last_index) == len(rows):
+ return rows
+ return [
+ rows[index]
+ for index in sorted(key_to_last_index.values())
+ ]
+
+ def _deduplicate_keys(self, keys: Sequence[object]) -> List[object]:
+ unique_keys = []
+ seen = set()
+ for key in keys:
+ if key in seen:
+ continue
+ seen.add(key)
+ unique_keys.append(key)
+ return unique_keys
+
+ def _commit_upsert_once(self, rows: List[Mapping[str, object]]) -> None:
+ generic_rows = self._rows_to_generic_rows(rows)
+ write_builder = self._raw_table.new_batch_write_builder()
+ table_update = write_builder.new_update()
+ table_commit = write_builder.new_commit()
+ try:
+ messages = table_update.upsert_by_key(
+ generic_rows, [self.key_column])
+ table_commit.commit(messages)
+ finally:
+ table_commit.close()
+
+ def _commit_column_updates(self, updates: Sequence[tuple]) -> None:
+ update_columns = self._column_update_names(updates)
+ targets = self._read_update_targets(
+ [key for key, _ in updates],
+ update_columns,
+ )
+ rows = []
+ row_ids_by_row = []
+ for key, columns in updates:
+ target = targets[key]
+ row = dict(target["columns"])
+ row.update(columns)
+ row[self.key_column] = key
+ rows.append(self._row_to_generic_row(row))
+ row_ids_by_row.append([target["row_id"]])
+
+ write_builder = self._raw_table.new_batch_write_builder()
+ table_commit = write_builder.new_commit()
+ try:
+ messages = TableUpdateByRowId(
+ self._raw_table,
+ write_builder.commit_user,
+ BATCH_COMMIT_IDENTIFIER,
+ ).update_rows_columns(rows, row_ids_by_row, update_columns)
+ if messages:
+ table_commit.commit(messages)
+ finally:
+ table_commit.close()
+
+ def _column_update_names(self, updates: Sequence[tuple]) -> List[str]:
+ requested = set()
+ for _, columns in updates:
+ requested.update(columns)
+ return [
+ name for name in self._raw_table.field_names
+ if name in requested
+ ]
+
+ def _read_update_targets(
+ self,
+ keys: Sequence[object],
+ columns: Sequence[str]) -> Dict[object, dict]:
+ read_builder = self._raw_table.new_read_builder()
+ projection = [self.key_column, SpecialFields.ROW_ID.name]
+ projection.extend(columns)
+ read_builder = read_builder.with_projection(projection)
+ read_builder = read_builder.with_filter(self._keys_predicate(keys))
+ plan = read_builder.new_scan().plan()
+ table = read_builder.new_read().to_arrow(plan.splits())
+
+ targets = {}
+ duplicates = []
+ for row in table.to_pylist():
+ key = row[self.key_column]
+ if key in targets:
+ duplicates.append(key)
+ continue
+ targets[key] = {
+ "row_id": row[SpecialFields.ROW_ID.name],
+ "columns": {
+ name: row.get(name)
+ for name in columns
+ },
+ }
+
+ missing = [key for key in keys if key not in targets]
+ if missing:
+ raise NoSuchKey(missing[0])
+ if duplicates:
+ raise ValueError("Multiple rows found for blob keys: %s" %
duplicates)
+ return targets
+
+ def _put_result_from_row(self, row: Mapping[str, object]) ->
PutObjectResult:
+ blob = row.get(self.column)
+ return PutObjectResult(
+ key=row[self.key_column],
+ size=_blob_size(blob),
+ )
+
+ def _rows_to_generic_rows(self, rows: List[Mapping[str, object]]) ->
List[GenericRow]:
+ return [
+ self._row_to_generic_row(row)
+ for row in rows
+ ]
+
+ def _row_to_generic_row(self, row: Mapping[str, object]) -> GenericRow:
+ return GenericRow(
+ [row.get(field.name) for field in self._raw_table.fields],
+ self._raw_table.fields,
+ )
+
+ def _read_rows(
+ self,
+ keys: Optional[Sequence[object]] = None,
+ include_blob: bool = False,
+ columns: Optional[Sequence[str]] = None) -> List[dict]:
+ read_table = self._raw_table.copy({
+ CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
+ }) if include_blob else self._raw_table
+ read_builder = read_table.new_read_builder()
+ projection = self._projection(include_blob, columns)
+ read_builder = read_builder.with_projection(projection)
+ if keys is not None:
+ read_builder = read_builder.with_filter(self._keys_predicate(keys))
+ plan = read_builder.new_scan().plan()
+ table = read_builder.new_read().to_arrow(plan.splits())
+ return table.to_pylist()
+
+ def _projection(
+ self,
+ include_blob: bool,
+ selected_columns: Optional[Sequence[str]]) -> List[str]:
+ projection = [self.key_column]
+ if include_blob:
+ projection.append(self.column)
+ projection.extend(self._selected_columns(selected_columns))
+ return projection
+
+ def _row_to_info(self, row: Mapping[str, object]) -> ObjectInfo:
+ descriptor_bytes = row.get(self.column)
+ descriptor = None
+ if descriptor_bytes is not None:
+ descriptor =
BlobDescriptor.deserialize(_bytes_value(descriptor_bytes))
+ columns = {
+ name: value
+ for name, value in row.items()
+ if name not in (self.key_column, self.column)
+ }
+ return ObjectInfo(
+ key=row[self.key_column],
+ size=descriptor.length if descriptor is not None else None,
+ descriptor=descriptor,
+ columns=columns,
+ )
+
+ def _keys_predicate(self, keys: Sequence[object]):
+ builder = PredicateBuilder(self._raw_table.fields)
+ values = list(keys)
+ if len(values) == 1:
+ return builder.equal(self.key_column, values[0])
+ return builder.is_in(self.key_column, values)
+
+ def _infer_blob_column(self) -> str:
+ blob_columns = [
+ field.name for field in self._raw_table.fields
+ if _is_blob_field(field)
+ ]
+ if len(blob_columns) != 1:
+ raise ValueError(
+ "Blob column is required when table has %d BLOB columns: %s."
+ % (len(blob_columns), blob_columns)
+ )
+ return blob_columns[0]
+
+ def _infer_key_column(self) -> str:
+ for candidate in ("key", "object_key", "path"):
+ if candidate in self._raw_table.field_names:
+ return candidate
+ raise ValueError(
+ "key_column is required; no default key/object_key/path column "
+ "exists in table schema."
+ )
+
+ def _validate_columns(self):
+ if self.key_column not in self._raw_table.field_names:
+ raise ValueError("key_column %r is not in table schema." %
self.key_column)
+ if self.column not in self._raw_table.field_names:
+ raise ValueError("blob column %r is not in table schema." %
self.column)
+ if not _is_blob_field(self._raw_table.field_dict[self.column]):
+ raise ValueError("Column %r is not a BLOB column." % self.column)
+ if self.key_column == self.column:
+ raise ValueError("key_column and blob column must be different.")
+
+ def _object_to_blob(self, obj: Mapping[str, object]) -> Blob:
+ has_body = "body" in obj and obj.get("body") is not None
+ has_reference = obj.get("descriptor") is not None or obj.get("uri") is
not None
+ if has_body and has_reference:
+ raise ValueError(
+ "Blob object spec must use either 'body' or descriptor/uri,
not both."
+ )
+ if has_reference:
+ descriptor = _coerce_descriptor(obj)
+ uri_reader =
self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
+ return Blob.from_descriptor(uri_reader, descriptor)
+ if not has_body:
+ raise ValueError("Blob object spec requires 'body', 'descriptor',
or 'uri'.")
+ body = obj.get("body")
+ if isinstance(body, Blob):
+ try:
+ descriptor = body.to_descriptor()
+ except RuntimeError:
+ return body
+ uri_reader =
self._raw_table.file_io.uri_reader_factory.create(descriptor.uri)
+ return Blob.from_descriptor(uri_reader, descriptor)
+ return BlobData(_body_to_bytes(body))
+
+ def _selected_columns(
+ self,
+ columns: Optional[Sequence[str]]) -> List[str]:
+ if columns is None:
+ return [
+ name for name in self._raw_table.field_names
+ if name not in (self.key_column, self.column)
+ ]
+ if isinstance(columns, str):
+ names = [columns]
+ else:
+ names = list(columns)
+ self._validate_unique_selected_columns(names)
+ for name in names:
+ if name in (self.key_column, self.column):
+ raise ValueError(
+ "columns must not include key or blob column %r." % name
+ )
+ if name not in self._raw_table.field_names:
+ raise ValueError("Column %r is not in table schema." % name)
+ return names
+
+ def _selected_update_columns(self, columns: Sequence[str]) -> List[str]:
+ names = self._selected_columns(columns)
+ partition_keys = set(self._raw_table.partition_keys)
+ for name in names:
+ if name in partition_keys:
+ raise ValueError(
+ "columns must not include partition column %r." % name
+ )
+ return names
+
+ @staticmethod
+ def _validate_unique_selected_columns(names: Sequence[str]):
+ seen = set()
+ duplicates = set()
+ for name in names:
+ if name in seen:
+ duplicates.add(name)
+ seen.add(name)
+ if duplicates:
+ raise ValueError("Duplicate columns: %s" % sorted(duplicates))
+
+ @staticmethod
+ def _validate_unique_keys(keys: Sequence[object]):
+ seen = set()
+ duplicates = set()
+ for key in keys:
+ if key in seen:
+ duplicates.add(key)
+ seen.add(key)
+ if duplicates:
+ raise ValueError("Duplicate blob keys: %s" % sorted(duplicates))
+
+
+def _require_key(obj: Mapping[str, object]):
+ if "key" not in obj:
+ raise ValueError("Blob object spec requires 'key'.")
+ key = obj["key"]
+ if key is None:
+ raise ValueError("Blob object key must not be None.")
+ return key
+
+
+def _body_to_bytes(body) -> bytes:
+ if isinstance(body, bytes):
+ return body
+ if isinstance(body, bytearray):
+ return bytes(body)
+ if isinstance(body, memoryview):
+ return body.tobytes()
+ if isinstance(body, str):
+ return body.encode("utf-8")
+ if isinstance(body, io.IOBase) or hasattr(body, "read"):
+ data = body.read()
+ if isinstance(data, str):
+ return data.encode("utf-8")
+ return bytes(data)
+ raise ValueError("Unsupported blob body type: %r." % type(body))
+
+
+def _coerce_descriptor(obj: Mapping[str, object]) -> BlobDescriptor:
+ descriptor = obj.get("descriptor")
+ if descriptor is not None:
+ if isinstance(descriptor, BlobDescriptor):
+ return descriptor
+ if isinstance(descriptor, (bytes, bytearray)):
+ return BlobDescriptor.deserialize(bytes(descriptor))
+ raise ValueError("descriptor must be BlobDescriptor or serialized
bytes.")
+
+ uri = obj.get("uri")
+ if not uri:
+ raise ValueError("Reference object spec requires 'uri' or
'descriptor'.")
+ return BlobDescriptor(
+ uri=str(uri),
+ offset=int(obj.get("offset", 0)),
+ length=int(obj.get("length", -1)),
+ )
+
+
+def _descriptor_for_range(
+ descriptor: BlobDescriptor,
+ range_header: Optional[str]) -> BlobDescriptor:
+ if range_header is None:
+ return descriptor
+ start, length = _parse_range(range_header, descriptor.length)
+ return BlobDescriptor(descriptor.uri, descriptor.offset + start, length)
+
+
+def _parse_range(range_header: str, total_length: int):
+ match = _RANGE_PATTERN.match(range_header)
+ if match is None:
+ raise ValueError("Range must use S3 syntax like 'bytes=0-1023'.")
+ start_text, end_text = match.groups()
+ if not start_text and not end_text:
+ raise ValueError("Range must specify a start or end byte.")
+
+ if start_text:
+ start = int(start_text)
+ if total_length >= 0 and start >= total_length:
+ raise ValueError("Range start is past the end of the object.")
+ if end_text:
+ end = int(end_text)
+ if end < start:
+ raise ValueError("Range end must be greater than or equal to
start.")
+ if total_length >= 0:
+ end = min(end, total_length - 1)
+ length = end - start + 1
+ else:
+ if total_length < 0:
+ length = -1
+ else:
+ length = max(total_length - start, 0)
+ else:
+ if total_length < 0:
+ raise ValueError("Suffix ranges require a known object length.")
+ suffix_length = int(end_text)
+ if suffix_length < 0:
+ raise ValueError("Suffix range length must be non-negative.")
+ length = min(suffix_length, total_length)
+ start = total_length - length
+
+ return start, length
+
+
+def _blob_descriptor(blob) -> Optional[BlobDescriptor]:
+ if blob is None:
+ return None
+ try:
+ return blob.to_descriptor()
+ except RuntimeError:
+ return None
+
+
+def _blob_size(blob) -> Optional[int]:
+ descriptor = _blob_descriptor(blob)
+ if descriptor is not None:
+ return descriptor.length if descriptor.length >= 0 else None
+ if isinstance(blob, BlobData):
+ return len(blob.data)
+ return None
+
+
+def _bytes_value(value) -> bytes:
+ if hasattr(value, "as_py"):
+ value = value.as_py()
+ if isinstance(value, bytearray):
+ value = bytes(value)
+ if not isinstance(value, bytes):
+ raise ValueError("Expected serialized BlobDescriptor bytes.")
+ return value
+
+
+def _is_blob_field(field) -> bool:
+ return getattr(field.type, "type", None) == "BLOB"
diff --git a/paimon-python/pypaimon/multimodal/connection.py
b/paimon-python/pypaimon/multimodal/connection.py
index b06940e620..2314eecded 100644
--- a/paimon-python/pypaimon/multimodal/connection.py
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -32,6 +32,7 @@ _DEFAULT_OPTIONS = {
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"deletion-vectors.enabled": "true",
+ "blob-as-descriptor": "true",
"file.format": "vortex",
"global-index.search-mode": "full",
"vector.file.format": "vortex",
diff --git a/paimon-python/pypaimon/multimodal/table.py
b/paimon-python/pypaimon/multimodal/table.py
index bc032d6644..916938137c 100644
--- a/paimon-python/pypaimon/multimodal/table.py
+++ b/paimon-python/pypaimon/multimodal/table.py
@@ -141,6 +141,10 @@ class MultimodalTable:
def scan(self):
return ScanQuery(self.raw_table)
+ def blobs(self, *, column: Optional[str] = None, key_column: Optional[str]
= None):
+ from pypaimon.multimodal.blob_store import BlobStore
+ return BlobStore(self, column=column, key_column=key_column)
+
def search(
self,
query,
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 1259ea7319..e745932c21 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import io
import json
import os
import shutil
@@ -103,6 +104,7 @@ class MultimodalTableTest(unittest.TestCase):
self.assertEqual("true", options["row-tracking.enabled"])
self.assertEqual("true", options["data-evolution.enabled"])
self.assertEqual("true", options["deletion-vectors.enabled"])
+ self.assertEqual("true", options["blob-as-descriptor"])
self.assertNotIn("data-evolution.row-sidecar.enabled", options)
self.assertEqual("vortex", options["file.format"])
self.assertEqual("full", options["global-index.search-mode"])
@@ -182,6 +184,257 @@ class MultimodalTableTest(unittest.TestCase):
self.assertEqual("ROW<rank: INT>", types_by_name["meta"])
self.assertEqual("VECTOR<FLOAT, 3>", types_by_name["embedding"])
+ def test_blob_store_put_objects_get_list_and_delete(self):
+ table = self.conn.create_table(
+ "objects",
+ schema=_schema({
+ "key": pa.string(),
+ "image": pa.large_binary(),
+ "content_type": pa.string(),
+ "owner": pa.string(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ store = table.blobs(column="image")
+
+ results = store.put_objects([
+ {
+ "key": "images/cat.jpg",
+ "body": b"cat-image-old",
+ "columns": {"content_type": "image/gif", "owner": "ignored"},
+ },
+ {
+ "key": "images/cat.jpg",
+ "body": b"cat-image-v1",
+ "columns": {"content_type": "image/jpeg", "owner": "alice"},
+ },
+ {
+ "key": "images/dog.jpg",
+ "body": bytearray(b"dog-image"),
+ "columns": {"content_type": "image/jpeg", "owner": "bob"},
+ },
+ ])
+
+ self.assertEqual(["images/cat.jpg", "images/dog.jpg"],
+ [result.key for result in results])
+ self.assertEqual([12, 9], [result.size for result in results])
+
+ cat = store.get_object("images/cat.jpg")
+ self.assertEqual(b"cat-image-v1", cat.read())
+ self.assertEqual(b"at-i", store.get_object(
+ "images/cat.jpg", range="bytes=1-4").read())
+ clipped = store.get_object("images/cat.jpg", range="bytes=10-999")
+ self.assertEqual(2, clipped.content_length)
+ self.assertEqual(b"v1", clipped.read())
+ with self.assertRaisesRegex(ValueError, "Range start"):
+ store.get_object("images/cat.jpg", range="bytes=12-13")
+ self.assertEqual("image/jpeg", cat.columns["content_type"])
+ self.assertEqual("alice", cat.columns["owner"])
+ owner_only = store.get_object(
+ "images/cat.jpg",
+ columns=["owner"],
+ )
+ self.assertEqual({"owner": "alice"}, owner_only.columns)
+
+ listed = store.list_objects(prefix="images/")
+ self.assertEqual(["images/cat.jpg", "images/dog.jpg"],
+ sorted(obj.key for obj in listed))
+ self.assertEqual(
+ {
+ "images/cat.jpg": "image/jpeg",
+ "images/dog.jpg": "image/jpeg",
+ },
+ {obj.key: obj.columns["content_type"] for obj in listed},
+ )
+ listed_without_columns = store.list_objects(
+ prefix="images/",
+ columns=[],
+ )
+ self.assertEqual(
+ {"images/cat.jpg": {}, "images/dog.jpg": {}},
+ {obj.key: obj.columns for obj in listed_without_columns},
+ )
+ self.assertEqual([], store.list_objects(prefix="images/", limit=0))
+ with self.assertRaisesRegex(ValueError, "limit"):
+ store.list_objects(prefix="images/", limit=-1)
+
+ store.put_object(
+ "images/cat.jpg",
+ b"cat-image-v2",
+ columns={"content_type": "image/png", "owner": "alice"},
+ )
+ self.assertEqual(b"cat-image-v2",
store.get_object("images/cat.jpg").read())
+ info = store.head_object("images/cat.jpg")
+ self.assertEqual("image/png", info.columns["content_type"])
+ self.assertEqual(
+ {"content_type": "image/png"},
+ store.head_object(
+ "images/cat.jpg",
+ columns="content_type",
+ ).columns,
+ )
+ self.assertEqual(2, table.scan().to_arrow().num_rows)
+
+ previous_descriptor = info.descriptor
+ updated = store.update_object_columns(
+ "images/cat.jpg",
+ {"content_type": "image/webp"},
+ )
+ self.assertEqual(previous_descriptor, updated.descriptor)
+ self.assertEqual("image/webp", updated.columns["content_type"])
+ self.assertEqual("alice", updated.columns["owner"])
+ self.assertEqual(b"cat-image-v2",
store.get_object("images/cat.jpg").read())
+
+ batch_updates = store.update_objects_columns([
+ {"key": "images/cat.jpg", "columns": {"owner": "carol"}},
+ {"key": "images/dog.jpg", "columns": {"owner": "dave"}},
+ ])
+ self.assertEqual(
+ ["images/cat.jpg", "images/dog.jpg"],
+ [obj.key for obj in batch_updates],
+ )
+ self.assertEqual("carol",
store.head_object("images/cat.jpg").columns["owner"])
+ self.assertEqual("dave",
store.head_object("images/dog.jpg").columns["owner"])
+ self.assertEqual(2, table.scan().to_arrow().num_rows)
+
+ with self.assertRaisesRegex(ValueError, "columns must not be empty"):
+ store.update_object_columns("images/cat.jpg", {})
+ with self.assertRaises(pmm.NoSuchKey):
+ store.update_object_columns("images/missing.jpg", {"owner":
"nobody"})
+ with self.assertRaisesRegex(ValueError, "columns must not include"):
+ store.update_object_columns("images/cat.jpg", {"image": b"new"})
+
+ store.delete_objects(["images/dog.jpg", "images/dog.jpg"])
+ with self.assertRaises(pmm.NoSuchKey):
+ store.head_object("images/dog.jpg")
+ self.assertEqual(["images/cat.jpg"],
+ [obj.key for obj in
store.list_objects(prefix="images/")])
+ store.delete_object("images/cat.jpg")
+ self.assertEqual([], store.list_objects(prefix="images/"))
+
+ def test_blob_store_put_object_accepts_blob_without_materializing(self):
+ from pypaimon.table.row.blob import Blob, BlobDescriptor
+
+ table = self.conn.create_table(
+ "streamed_objects",
+ schema=_schema({
+ "key": pa.string(),
+ "payload": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ data = b"streamed-managed-payload"
+ stream_uri = "stream://payloads/1"
+ stream_calls = []
+
+ class StreamOnlyReader:
+
+ def new_input_stream(self, uri):
+ stream_calls.append(("new_input_stream", uri))
+ return io.BytesIO(data)
+
+ class StreamOnlyReaderFactory:
+
+ def create(self, uri):
+ stream_calls.append(("create", uri))
+ return StreamOnlyReader()
+
+ class DescriptorOnlyBlob(Blob):
+
+ def to_data(self):
+ raise AssertionError("put_object should not materialize Blob
data")
+
+ def to_descriptor(self):
+ return BlobDescriptor(stream_uri, 0, len(data))
+
+ def new_input_stream(self):
+ raise AssertionError("put_object should use the URI stream")
+
+ store = table.blobs(column="payload")
+ original_factory = table.raw_table.file_io.uri_reader_factory
+ table.raw_table.file_io.uri_reader_factory = StreamOnlyReaderFactory()
+ try:
+ result = store.put_object("payloads/1", DescriptorOnlyBlob())
+ finally:
+ table.raw_table.file_io.uri_reader_factory = original_factory
+
+ self.assertEqual(len(data), result.size)
+ self.assertEqual([
+ ("create", stream_uri),
+ ("new_input_stream", stream_uri),
+ ], stream_calls)
+ stored = store.get_object("payloads/1")
+ self.assertNotEqual(stream_uri, stored.descriptor.uri)
+ self.assertEqual(data, stored.read())
+
+ def test_blob_store_put_object_reference_preserves_descriptor_uri(self):
+ table = self.conn.create_table(
+ "referenced_objects",
+ schema=_schema({
+ "object_key": pa.string(),
+ "payload": pa.large_binary(),
+ "media_type": pa.string(),
+ }),
+ options=dict(_PARQUET_OPTIONS, **{
+ "blob-descriptor-field": "payload",
+ }),
+ )
+ data = b"external-video-payload"
+ external_path = os.path.join(self.temp_dir, "video.bin")
+ with open(external_path, "wb") as f:
+ f.write(data)
+
+ store = table.blobs(column="payload")
+ result = store.put_object(
+ "videos/1",
+ uri=external_path,
+ length=len(data),
+ columns={"media_type": "video/mp4"},
+ )
+ descriptor = store.head_object("videos/1").descriptor
+ more = store.put_objects([
+ {
+ "key": "videos/2",
+ "descriptor": descriptor,
+ "columns": {"media_type": "video/mp4"},
+ }
+ ])
+
+ self.assertEqual("videos/1", result.key)
+ self.assertEqual(len(data), result.size)
+ self.assertEqual("videos/2", more[0].key)
+ obj = store.get_object("videos/1")
+ 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_put_object_uri_streams_into_managed_blob(self):
+ table = self.conn.create_table(
+ "managed_only_objects",
+ schema=_schema({
+ "key": pa.string(),
+ "payload": pa.large_binary(),
+ }),
+ options=_PARQUET_OPTIONS,
+ )
+ data = b"external-managed-payload"
+ external_path = os.path.join(self.temp_dir, "managed.bin")
+ with open(external_path, "wb") as f:
+ f.write(data)
+
+ store = table.blobs(column="payload")
+ result = store.put_object(
+ "payloads/1",
+ uri=external_path,
+ length=len(data),
+ )
+
+ self.assertEqual(len(data), result.size)
+ stored = store.get_object("payloads/1")
+ self.assertNotEqual(external_path, stored.descriptor.uri)
+ self.assertEqual(data, stored.read())
+
def test_drop_table_can_ignore_missing_table(self):
self.conn.drop_table("missing", ignore_if_not_exists=True)