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 ce35a70072 [python] Add HDF5 DataSource ingestion (#9411)
ce35a70072 is described below
commit ce35a70072317191fd3473f0fc7913e8850dd3f4
Author: Yann Byron <[email protected]>
AuthorDate: Fri Aug 28 13:52:09 2026 +0800
[python] Add HDF5 DataSource ingestion (#9411)
---
.github/workflows/paimon-python-checks.yml | 2 +
docs/docs/pypaimon/multimodal-api.mdx | 126 +++
paimon-python/README.md | 78 +-
paimon-python/dev/requirements-dev.txt | 2 +
paimon-python/pypaimon/filesystem/local_file_io.py | 47 +-
.../pypaimon/filesystem/pyarrow_file_io.py | 6 +-
paimon-python/pypaimon/multimodal/__init__.py | 6 +
paimon-python/pypaimon/multimodal/connection.py | 21 +
paimon-python/pypaimon/multimodal/hdf5.py | 626 ++++++++++++++
paimon-python/pypaimon/tests/file_io_test.py | 43 +-
paimon-python/pypaimon/tests/hdfs_native_test.py | 8 +
.../pypaimon/tests/multimodal_hdf5_test.py | 949 +++++++++++++++++++++
.../pypaimon/tests/oss_legacy_mode_test.py | 7 +-
paimon-python/setup.py | 4 +
14 files changed, 1905 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index c66101d8e6..5b35fff4f8 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -137,6 +137,8 @@ jobs:
else
python -m pip install pyarrow==16.0.0 numpy==1.24.3
pandas==2.0.3 flake8==4.0.1
fi
+ python -m pip install 'h5py>=3,<4'
+ python -c "import h5py; print('h5py', h5py.__version__)"
if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
# Exercise the 0.4 API in one lane until its wheel is published.
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index 2bb3d2562c..995966a77c 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -190,6 +190,132 @@ docs.add([
])
```
+## Load HDF5
+
+`MultimodalConnection.load_from_hdf5` streams one or more local or remote HDF5
+files into an existing multimodal table. HDF5 loading requires Python 3.8 or
+newer. Install the optional dependency first:
+
+```shell
+pip install 'pypaimon[hdf5]'
+```
+
+The transform receives an open `h5py.File` and an `Hdf5File`. Its `path` is the
+resolved local `file://` URI or remote URI; `name` and `stem` provide
convenient
+path components. For local sources, `local_path` returns a decoded `Path` that
+can be used to read sibling files, including paths containing spaces or
Unicode;
+it is `None` for remote sources. The transform can return one Arrow table or
+record batch, or an iterable that yields multiple tables or batches:
+
+```python
+import pyarrow as pa
+
+EMBEDDING_VECTOR_TYPE = pa.list_(pa.float32(), 3)
+IMAGE_BLOB_TYPE = pa.large_binary()
+
+schema = pa.schema([
+ pa.field("episode_id", pa.string(), nullable=False),
+ pa.field("frame_index", pa.int32(), nullable=False),
+ # Arrow fixed-size lists map to Paimon VECTOR columns.
+ pa.field("embedding", EMBEDDING_VECTOR_TYPE, nullable=False),
+ # Arrow binary and large-binary values map to Paimon BLOB columns.
+ pa.field("image", IMAGE_BLOB_TYPE),
+])
+
+frames = conn.create_table("frames", schema=schema)
+
+
+def transform(h5, source):
+ episode_id = source.stem
+ for begin in range(0, len(h5["embedding"]), 128):
+ end = min(begin + 128, len(h5["embedding"]))
+ yield pa.RecordBatch.from_pydict({
+ "episode_id": [episode_id] * (end - begin),
+ "frame_index": list(range(begin, end)),
+ "embedding": h5["embedding"][begin:end].tolist(),
+ "image": [bytes(value) for value in h5["image"][begin:end]],
+ }, schema=schema)
+
+
+result = conn.load_from_hdf5(
+ "frames",
+ "/data/episodes",
+ transform=transform,
+)
+print(result.file_count)
+print(result.batch_count)
+print(result.row_count)
+print(result.snapshot_id)
+```
+
+A path can be one `.h5` or `.hdf5` file, an iterable of paths, or a directory.
+Directories are searched recursively. Before opening files, `load_from_hdf5`
+resolves every discovered path, removes duplicate files within that call, and
+sorts by the resolved path. Overlapping directory and file arguments therefore
+read a physical file once per call. Local paths and `file://`, `hdfs://`,
+`viewfs://`, `oss://`, `s3://`, and `gs://` URIs use PyPaimon's existing
+FileIO implementations.
+
+Remote credentials and endpoints belong to the HDF5 source, not necessarily
+the target warehouse. Pass standard FileIO settings through `source_options`;
+they are not inherited from the target table, written to table options, or
+retained after the call. This also applies to HDFS client settings: provide
+source-specific settings explicitly, while configuration discoverable by the
+underlying filesystem client can still come from its normal environment:
+
+```python
+remote_result = conn.load_from_hdf5(
+ "frames",
+ "oss://source-bucket/episodes",
+ transform=transform,
+ source_options={
+ "fs.oss.endpoint": "oss-cn-hangzhou.aliyuncs.com",
+ "fs.oss.accessKeyId": "SOURCE_ACCESS_KEY_ID",
+ "fs.oss.accessKeySecret": "SOURCE_ACCESS_KEY_SECRET",
+ },
+)
+```
+
+Because native HDFS Kerberos credentials use process-global ticket state, HDF5
+sources cannot run an explicit-keytab login in a shared process. Acquire the
+ticket in a process-isolated worker, omit the source principal/keytab options,
+and then call `load_from_hdf5` in that worker.
+
+HDF5 requires random access. `load_from_hdf5` passes the seekable stream
returned
+by `FileIO.new_input_stream` directly to h5py and never downloads it to a local
+temporary file. A non-seekable stream fails before commit. Recursive directory
+discovery requires backend listing support; legacy OSS on PyArrow before 16
+must use explicit file paths, Jindo, or a newer PyArrow version. HDF5 performs
+many small seeks, so benchmark remote-object latency for large production
+inputs even though no full-file download is required.
+
+An empty path iterable or existing directory with no HDF5 files is a no-op. It
+returns zero file, batch, and row counts with `snapshot_id=None`, without
+creating a writer or snapshot. A nonexistent path or unsupported file suffix
+is still an error, as is a discovered file whose transform produces no rows.
+
+The target schema is strict. Each batch must contain exactly the target columns
+in target order and must be safely convertible to the target Arrow schema.
+Missing columns are not filled with null, extra columns are not discarded, and
+invalid nullability, incompatible types, or fixed-size vector lengths fail the
+whole call.
+
+One call uses one writer and one commit for all discovered files, and a
+successful non-empty call creates exactly one snapshot. Any failure before the
+commit aborts the writer and leaves no partial snapshot. An exception after the
+commit starts has Paimon's unknown-commit-result semantics: `load_from_hdf5`
does
+not retry and does not abort files that a snapshot may already reference.
+
+For a future Ray integration, split HDF5 file descriptions across worker tasks,
+apply the same transform and strict batch contract in each task, and collect
+writer commit messages for one coordinator commit. Do not call `load_from_hdf5`
+independently in every task, because that would create one commit per task. The
+HDF5 core itself has no Ray dependency.
+
+`load_from_hdf5` is append-only and is **not retry-safe**. It does not add
source
+provenance columns, maintain a source ledger, skip prior inputs, or detect
+source drift. Calling it again with the same input appends the rows again.
+
## Overwrite
`overwrite` accepts the same input formats as `add` and replaces existing data
diff --git a/paimon-python/README.md b/paimon-python/README.md
index 5f716c8828..0e213985af 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -31,6 +31,83 @@ pip3 install dist/*.tar.gz
The command will install the package and core dependencies to your local
Python environment.
+# HDF5 to multimodal tables
+
+HDF5 loading requires Python 3.8 or newer. Install the optional dependency and
+create the target multimodal table before loading local or remote HDF5 files as
+one or more Arrow batches:
+
+```commandline
+pip install 'pypaimon[hdf5,vortex]'
+```
+
+```python
+import pyarrow as pa
+import pypaimon.multimodal as pmm
+
+EMBEDDING_VECTOR_TYPE = pa.list_(pa.float32(), 3)
+IMAGE_BLOB_TYPE = pa.large_binary()
+
+schema = pa.schema([
+ pa.field("episode_id", pa.string(), nullable=False),
+ pa.field("frame_index", pa.int32(), nullable=False),
+ # Arrow fixed-size lists map to Paimon VECTOR columns.
+ pa.field("embedding", EMBEDDING_VECTOR_TYPE, nullable=False),
+ # Arrow binary and large-binary values map to Paimon BLOB columns.
+ pa.field("image", IMAGE_BLOB_TYPE),
+])
+
+
+def transform(h5, source):
+ episode_id = source.stem
+ for begin in range(0, len(h5["embedding"]), 128):
+ end = min(begin + 128, len(h5["embedding"]))
+ yield pa.RecordBatch.from_pydict({
+ "episode_id": [episode_id] * (end - begin),
+ "frame_index": list(range(begin, end)),
+ "embedding": h5["embedding"][begin:end].tolist(),
+ "image": [bytes(value) for value in h5["image"][begin:end]],
+ }, schema=schema)
+
+connection = pmm.connect(options={"warehouse": "/tmp/warehouse"})
+frames = connection.create_table(
+ "frames",
+ schema=schema,
+)
+result = connection.load_from_hdf5(
+ "frames", "/data/episodes", transform=transform)
+print(result.file_count, result.batch_count, result.row_count,
result.snapshot_id)
+```
+
+`load_from_hdf5` accepts one `.h5`/`.hdf5` file, an iterable of paths, or
+directories that are searched recursively. Paths are resolved, duplicate
+files within the call are removed, and the remaining files are processed in
+sorted order. Every yielded batch must have exactly the target columns and be
+safely convertible to the table schema; missing or extra columns, nulls for
+non-nullable fields, incompatible types, and invalid fixed-size vector lengths
+fail the call.
+
+Remote `hdfs://`, `viewfs://`, `oss://`, `s3://`, and `gs://` sources use
+PyPaimon's FileIO abstraction. Pass source-only credentials and endpoints via
+`source_options={"fs.oss.endpoint": "...", ...}`; target warehouse FileIO
+settings are deliberately not reused. h5py reads the seekable FileIO stream
+directly without a local temporary download. Legacy OSS with PyArrow before 16
+supports explicit files but requires Jindo or a newer PyArrow for recursive
+directory discovery. In transforms, `source.local_path` returns a decoded
+`Path` for local sources (including spaces and Unicode) and `None` for remote
+sources.
+
+An empty path iterable or an existing directory without HDF5 files returns
+zero counts and `snapshot_id=None` without creating a writer or snapshot.
+Nonexistent paths, unsupported file suffixes, and discovered files whose
+transform produces no rows remain errors.
+
+All files in one call use one writer and one commit, so success creates one
+snapshot. The API is append-only: it does not add provenance columns, keep a
+source ledger, skip files, or detect drift. Repeating the same call appends the
+rows again. It is not retry-safe because an exception from the commit can have
+an unknown result; inspect table state before deciding whether to retry.
+
# HDFS without a local Hadoop install
`pypaimon` supports HDFS through a pure-protocol client based on
@@ -109,4 +186,3 @@ unsupported platform such as Windows), `pypaimon`
automatically falls
back to the `pyarrow` (`libhdfs`/JVM) path and logs a warning. Disable
the fallback with `hdfs.client.fallback-to-pyarrow=false` if you want
hard failures instead.
-
diff --git a/paimon-python/dev/requirements-dev.txt
b/paimon-python/dev/requirements-dev.txt
index 28da953b3b..a1b143383c 100644
--- a/paimon-python/dev/requirements-dev.txt
+++ b/paimon-python/dev/requirements-dev.txt
@@ -21,6 +21,8 @@
duckdb==1.3.2
flake8==4.0.1
pytest~=7.0
+# HDF5 ingestion tests run in the supported h5py wheel lanes (Python 3.8+).
+h5py>=3,<4; python_version >= "3.8"
# merge_into needs Dataset.join (added in Ray 2.50). Python 3.8 has no 2.50
wheel.
ray>=2.10.0; python_version < "3.9"
ray>=2.50.0; python_version >= "3.9"
diff --git a/paimon-python/pypaimon/filesystem/local_file_io.py
b/paimon-python/pypaimon/filesystem/local_file_io.py
index b315226181..e3530c1951 100644
--- a/paimon-python/pypaimon/filesystem/local_file_io.py
+++ b/paimon-python/pypaimon/filesystem/local_file_io.py
@@ -22,7 +22,7 @@ import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, Optional
-from urllib.parse import urlparse
+from urllib.parse import unquote, urlparse
import pyarrow
import pyarrow.fs as pafs
@@ -35,6 +35,24 @@ from pypaimon.schema.data_types import DataField,
AtomicType, PyarrowFieldParser
from pypaimon.write.blob_format_writer import BlobFormatWriter
+def _file_uri_path(parsed, windows=None) -> str:
+ """Decode a file URI path, preserving Windows drives and UNC hosts."""
+ if windows is None:
+ windows = os.name == "nt"
+ if windows:
+ from nturl2path import url2pathname as windows_url2pathname
+ if parsed.netloc and not parsed.netloc.endswith(":"):
+ value = "//%s%s" % (parsed.netloc, parsed.path)
+ elif parsed.netloc:
+ value = "/%s%s" % (parsed.netloc, parsed.path)
+ else:
+ value = parsed.path
+ return windows_url2pathname(value)
+ if parsed.netloc and parsed.netloc != "localhost":
+ return "//%s%s" % (parsed.netloc, unquote(parsed.path))
+ return unquote(parsed.path)
+
+
class LocalFileIO(FileIO):
"""
Local file system implementation of FileIO.
@@ -61,13 +79,14 @@ class LocalFileIO(FileIO):
if parsed.scheme == 'file' and parsed.netloc and
parsed.netloc.endswith(':'):
drive_letter = parsed.netloc.rstrip(':')
- path_part = parsed.path.lstrip('/') if parsed.path else ''
+ path_part = unquote(parsed.path).lstrip('/') if parsed.path else ''
if path_part:
return Path(f"{drive_letter}:/{path_part}")
else:
return Path(f"{drive_letter}:")
- local_path = parsed.path if parsed.scheme else path
+ local_path = (
+ _file_uri_path(parsed) if parsed.scheme == 'file' else path)
if not local_path:
return Path(".")
@@ -138,18 +157,16 @@ class LocalFileIO(FileIO):
if file_path.is_file():
results.append(self.get_file_status(path))
elif file_path.is_dir():
- try:
- for item in file_path.iterdir():
- try:
- if path.startswith('file://'):
- item_path = f"file://{item}"
- else:
- item_path = str(item)
- results.append(self.get_file_status(item_path))
- except FileNotFoundError:
- pass
- except PermissionError:
- pass
+ for item in file_path.iterdir():
+ try:
+ if path.startswith('file://'):
+ item_path = item.absolute().as_uri()
+ else:
+ item_path = str(item)
+ results.append(self.get_file_status(item_path))
+ except FileNotFoundError:
+ # A child may disappear between listing and stat.
+ pass
return results
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 4423c9559e..362994fb65 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -45,6 +45,10 @@ def _pyarrow_lt_7():
return parse(pyarrow.__version__) < parse("7.0.0")
+class LegacyOssDirectoryListingError(RuntimeError):
+ """Raised when legacy PyArrow OSS cannot enumerate a directory."""
+
+
class PyArrowFileIO(FileIO):
def __init__(self, path: str, catalog_options: Options):
self.properties = catalog_options
@@ -402,7 +406,7 @@ class PyArrowFileIO(FileIO):
def list_status(self, path: str):
if self._legacy_oss_mode():
- raise RuntimeError(
+ raise LegacyOssDirectoryListingError(
"Listing OSS directories is not supported with PyArrow < 16 "
"(it parses the first key segment as a bucket). Upgrade to "
"pyarrow >= 16, or install pyjindosdk and set
fs.oss.impl=jindo.")
diff --git a/paimon-python/pypaimon/multimodal/__init__.py
b/paimon-python/pypaimon/multimodal/__init__.py
index 1b5060ec8f..96afcbbea3 100644
--- a/paimon-python/pypaimon/multimodal/__init__.py
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -25,6 +25,10 @@ from pypaimon.multimodal.blob_store import (
PutObjectResult,
)
from pypaimon.multimodal.connection import MultimodalConnection, connect
+from pypaimon.multimodal.hdf5 import (
+ Hdf5File,
+ Hdf5LoadResult,
+)
from pypaimon.multimodal.table import (
MultimodalTable,
TextRoute,
@@ -41,6 +45,8 @@ from pypaimon.table.data_evolution_merge_into import (
__all__ = [
"BlobObject",
"BlobStore",
+ "Hdf5File",
+ "Hdf5LoadResult",
"MultimodalConnection",
"MultimodalTable",
"NoSuchKey",
diff --git a/paimon-python/pypaimon/multimodal/connection.py
b/paimon-python/pypaimon/multimodal/connection.py
index 8bd1c90777..ff90820daf 100644
--- a/paimon-python/pypaimon/multimodal/connection.py
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -96,6 +96,27 @@ class MultimodalConnection:
raw_table,
)
+ def load_from_hdf5(
+ self,
+ table_name: str,
+ paths,
+ *,
+ transform,
+ source_options=None):
+ """Load HDF5 transforms into an existing table as one append commit.
+
+ Repeating a call appends the rows again. A commit exception has an
+ unknown result and is not safe to retry without checking table state.
+ Source filesystem options are isolated from the target warehouse.
+ """
+ from pypaimon.multimodal.hdf5 import load_from_hdf5
+ return load_from_hdf5(
+ self.get_table(table_name),
+ paths,
+ transform=transform,
+ source_options=source_options,
+ )
+
def drop_table(self, name: str, ignore_if_not_exists: bool = False):
self.catalog.drop_table(
self._identifier(name),
diff --git a/paimon-python/pypaimon/multimodal/hdf5.py
b/paimon-python/pypaimon/multimodal/hdf5.py
new file mode 100644
index 0000000000..68389cf4e2
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/hdf5.py
@@ -0,0 +1,626 @@
+# 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.
+
+"""Strict append-only ingestion from seekable HDF5 sources."""
+
+import os
+import re
+import sys
+from contextlib import closing
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath, PureWindowsPath
+from typing import Callable, Mapping, Optional
+from urllib.parse import quote, unquote, urlparse, urlunparse
+
+import pyarrow as pa
+import pyarrow.compute as pc
+import pyarrow.fs as pafs
+
+from pypaimon.common.options import Options
+from pypaimon.filesystem.local_file_io import _file_uri_path
+from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
+from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+from pypaimon.multimodal.table import _target_schema
+from pypaimon.write.commit_callback import CommitCallback
+
+
+_HDF5_SUFFIXES = (".h5", ".hdf5")
+
+
+@dataclass(frozen=True)
+class Hdf5File:
+ """Read context supplied to an HDF5 transform."""
+
+ path: str
+
+ @property
+ def local_path(self) -> Optional[Path]:
+ """Decoded local path, or ``None`` for a remote source."""
+ parsed = urlparse(self.path)
+ if parsed.scheme.lower() != "file":
+ return None
+ return Path(_file_uri_path(parsed))
+
+ @property
+ def name(self) -> str:
+ """Base name of the local path or remote URI."""
+ local_path = self.local_path
+ if local_path is not None:
+ return local_path.name
+ parsed = urlparse(self.path)
+ path = unquote(parsed.path) if parsed.scheme else self.path
+ return PurePosixPath(path).name
+
+ @property
+ def stem(self) -> str:
+ """Base name without the final HDF5 suffix."""
+ return PurePosixPath(self.name).stem
+
+
+@dataclass(frozen=True)
+class Hdf5LoadResult:
+ """Counts and optional snapshot for one ``load_from_hdf5`` call."""
+
+ file_count: int
+ batch_count: int
+ row_count: int
+ snapshot_id: Optional[int]
+
+
+class _SnapshotRecorder(CommitCallback):
+
+ def __init__(self):
+ self.snapshot_id = None
+
+ def call(self, context):
+ self.snapshot_id = context.snapshot.id
+
+
+class _Hdf5SourceFileIO:
+ """Resolve HDF5 URIs while keeping decoding local to external sources."""
+
+ def __init__(self, options):
+ self._resolver = ResolvingFileIO(options)
+
+ def _resolve(self, path):
+ file_io = self._resolver._get_fileio(path)
+ native_path = file_io.to_filesystem_path(path)
+ if urlparse(path).scheme.lower() != "file":
+ native_path = unquote(native_path)
+ return file_io, native_path
+
+ def get_file_status(self, path):
+ file_io, native_path = self._resolve(path)
+ return file_io.get_file_status(native_path)
+
+ def list_status(self, path):
+ file_io, native_path = self._resolve(path)
+ return file_io.list_status(native_path)
+
+ def new_input_stream(self, path):
+ file_io, native_path = self._resolve(path)
+ return file_io.new_input_stream(native_path)
+
+ def close(self):
+ self._resolver.close()
+
+
+def load_from_hdf5(
+ table,
+ paths,
+ *,
+ transform: Callable,
+ source_options: Optional[Mapping[str, object]] = None):
+ """Load HDF5 files into an existing multimodal table.
+
+ ``transform`` receives an open ``h5py.File`` and :class:`Hdf5File`, and
+ must return one Arrow table/batch or an iterable of Arrow tables/batches.
+ All unique files and batches in one call share one writer and one commit.
+ Local paths and FileIO-supported URIs are accepted. ``source_options`` are
+ used only for source FileIO resolution and are never inherited from the
+ target table's warehouse.
+
+ This API is strictly append-only. It does not track sources or detect
+ duplicates between calls, so calling it again writes the rows again. It is
+ not retry-safe: a commit exception may have happened after the snapshot
+ became visible and is returned without retrying or aborting written files.
+ Empty discovery is a no-op and returns zero counts with no snapshot.
+ """
+ if sys.version_info < (3, 8):
+ raise RuntimeError(
+ "load_from_hdf5 requires Python 3.8 or newer; the hdf5 extra "
+ "is not available on older Python versions.")
+ if not callable(transform):
+ raise ValueError("transform must be callable.")
+ validated_options = _validated_source_options(source_options)
+ path_values = _path_values(paths)
+ _validate_source_kerberos(path_values, validated_options)
+ source_file_io = _Hdf5SourceFileIO(Options(validated_options))
+ try:
+ files = _discover_hdf5_files(path_values, source_file_io)
+ if not files:
+ return Hdf5LoadResult(
+ file_count=0,
+ batch_count=0,
+ row_count=0,
+ snapshot_id=None,
+ )
+ # Preserve the dependency-free no-op for empty discovery; only require
+ # h5py once at least one HDF5 source will actually be opened.
+ try:
+ import h5py
+ except ImportError as error:
+ raise ImportError(
+ "load_from_hdf5 requires h5py; install 'pypaimon[hdf5]'."
+ ) from error
+ return _load_hdf5_files(
+ table, files, transform, source_file_io, h5py)
+ finally:
+ source_file_io.close()
+
+
+def _load_hdf5_files(table, files, transform, source_file_io, h5py):
+ target_schema = _target_schema(table.raw_table)
+ write_builder = table.raw_table.new_batch_write_builder()
+ table_write = None
+ table_commit = None
+ commit_started = False
+ batch_count = 0
+ row_count = 0
+ snapshot_recorder = _SnapshotRecorder()
+
+ try:
+ table_write = write_builder.new_write()
+ table_commit = write_builder.new_commit()
+ table_commit.add_commit_callback(snapshot_recorder)
+
+ for source in files:
+ source_row_count = 0
+ with closing(source_file_io.new_input_stream(source.path)) as
stream:
+ _require_seekable(stream, source)
+ with h5py.File(stream, "r") as h5:
+ transformed = transform(h5, source)
+ batches = None
+ try:
+ batches = _arrow_batches(transformed)
+ for value in batches:
+ arrow_table = _strict_arrow_table(
+ value,
+ target_schema,
+ source,
+ batch_count,
+ )
+ batch_count += 1
+ row_count += arrow_table.num_rows
+ source_row_count += arrow_table.num_rows
+ if arrow_table.num_rows:
+ table_write.write_arrow(arrow_table)
+ finally:
+ _close_transform_iterator(
+ batches if batches is not None else transformed)
+
+ if source_row_count == 0:
+ raise ValueError(
+ "HDF5 source %s produced no rows." % source.path)
+
+ commit_messages = table_write.prepare_commit()
+ commit_started = True
+ table_commit.commit(commit_messages)
+ if snapshot_recorder.snapshot_id is None:
+ raise RuntimeError(
+ "HDF5 append committed without reporting a snapshot id.")
+ return Hdf5LoadResult(
+ file_count=len(files),
+ batch_count=batch_count,
+ row_count=row_count,
+ snapshot_id=snapshot_recorder.snapshot_id,
+ )
+ except BaseException:
+ if table_write is not None and not commit_started:
+ table_write.abort()
+ raise
+ finally:
+ try:
+ if table_write is not None:
+ table_write.close()
+ finally:
+ if table_commit is not None:
+ table_commit.close()
+
+
+def _discover_hdf5_files(paths, source_file_io):
+ values = _path_values(paths)
+ normalized = {}
+ visited_directories = set()
+ for value in values:
+ input_path = _source_path_text(value)
+ path = _normalize_source_path(input_path)
+ try:
+ status = source_file_io.get_file_status(path)
+ except FileNotFoundError as error:
+ _discover_missing_path(
+ source_file_io,
+ path,
+ normalized,
+ visited_directories,
+ error,
+ input_path,
+ )
+ continue
+ _discover_status(
+ source_file_io,
+ path,
+ status,
+ normalized,
+ visited_directories,
+ explicit_path=input_path,
+ )
+ return [normalized[key] for key in sorted(normalized)]
+
+
+def _discover_missing_path(
+ source_file_io,
+ path,
+ normalized,
+ visited_directories,
+ not_found_error,
+ input_path):
+ if _hdf5_suffix(path):
+ raise ValueError(
+ "HDF5 path does not exist: %s" % input_path
+ ) from not_found_error
+ try:
+ children = source_file_io.list_status(path)
+ except LegacyOssDirectoryListingError as error:
+ _raise_legacy_directory_listing_error(path, error)
+ if not children:
+ raise ValueError(
+ "HDF5 path does not exist: %s" % input_path
+ ) from not_found_error
+ visited_directories.add(path)
+ _discover_directory_children(
+ source_file_io,
+ path,
+ children,
+ normalized,
+ visited_directories,
+ )
+
+
+def _discover_status(
+ source_file_io,
+ parent_path,
+ status,
+ normalized,
+ visited_directories,
+ explicit_path=None):
+ path = _qualified_status_path(parent_path, status)
+ if status.type == pafs.FileType.File:
+ if not _hdf5_suffix(path):
+ if explicit_path is not None:
+ raise ValueError(
+ "HDF5 file has unsupported suffix: %s; expected .h5 or "
+ ".hdf5." % explicit_path)
+ return
+ normalized[path] = Hdf5File(path=path)
+ return
+ if status.type != pafs.FileType.Directory:
+ raise ValueError("Unsupported HDF5 source status for path: %s" % path)
+ if path in visited_directories:
+ return
+ visited_directories.add(path)
+ try:
+ children = source_file_io.list_status(path)
+ except LegacyOssDirectoryListingError as error:
+ _raise_legacy_directory_listing_error(path, error)
+ _discover_directory_children(
+ source_file_io,
+ path,
+ children,
+ normalized,
+ visited_directories,
+ )
+
+
+def _discover_directory_children(
+ source_file_io,
+ directory,
+ children,
+ normalized,
+ visited_directories):
+ for child in children:
+ _discover_status(
+ source_file_io,
+ directory,
+ child,
+ normalized,
+ visited_directories,
+ explicit_path=None,
+ )
+
+
+def _raise_legacy_directory_listing_error(path, error):
+ raise ValueError(
+ "Recursive HDF5 discovery is unavailable for legacy OSS at %s; "
+ "pass explicit HDF5 file paths, use Jindo, or upgrade PyArrow."
+ % path) from error
+
+
+def _source_path_text(value):
+ try:
+ path = os.fspath(value)
+ except TypeError as error:
+ raise ValueError(
+ "paths must contain only filesystem paths or URIs.") from error
+ if isinstance(path, bytes):
+ raise ValueError("paths must contain only filesystem paths or URIs.")
+ return path
+
+
+def _normalize_source_path(value):
+ path = _source_path_text(value)
+ parsed = urlparse(path)
+ if _is_windows_drive_path(parsed):
+ windows_path = PureWindowsPath(path)
+ if not windows_path.is_absolute():
+ raise ValueError("Windows source paths must be absolute: %s" %
path)
+ return "file:///%s" % quote(windows_path.as_posix(), safe="/:")
+ if not parsed.scheme:
+ return Path(path).expanduser().resolve().as_uri()
+ return _quote_uri_path(path)
+
+
+def _quote_uri_path(uri):
+ match = re.match(r"^([A-Za-z][A-Za-z0-9+.-]*://[^/]*)(.*)$", uri)
+ if match is None:
+ return uri
+ return match.group(1) + quote(match.group(2), safe="/:%")
+
+
+def _qualified_status_path(parent_path, status):
+ status_path = str(status.path)
+ status_uri = urlparse(status_path)
+ if status_uri.scheme and not _is_windows_drive_path(status_uri):
+ return _quote_uri_path(status_path)
+
+ parent_uri = urlparse(parent_path)
+ scheme = parent_uri.scheme.lower()
+ if scheme == "file":
+ return _normalize_source_path(status_path)
+ if not scheme or _is_windows_drive_path(parent_uri):
+ return _normalize_source_path(status_path)
+
+ if scheme in ("hdfs", "viewfs"):
+ return urlunparse((
+ scheme,
+ parent_uri.netloc,
+ quote("/" + status_path.lstrip("/"), safe="/:"),
+ "",
+ "",
+ "",
+ ))
+
+ key = status_path.lstrip("/")
+ if parent_uri.netloc and not (
+ key == parent_uri.netloc
+ or key.startswith(parent_uri.netloc + "/")):
+ key = parent_uri.netloc + "/" + key
+ return "%s://%s" % (scheme, quote(key, safe="/:"))
+
+
+def _is_windows_drive_path(parsed):
+ return len(parsed.scheme) == 1 and not parsed.netloc
+
+
+def _hdf5_suffix(path):
+ parsed = urlparse(path)
+ return PurePosixPath(unquote(parsed.path)).suffix.lower() in _HDF5_SUFFIXES
+
+
+def _path_values(paths):
+ if isinstance(paths, (str, os.PathLike)):
+ return [paths]
+ if isinstance(paths, bytes):
+ raise ValueError("paths must be a path or an iterable of paths.")
+ try:
+ return list(paths)
+ except TypeError as error:
+ raise ValueError(
+ "paths must be a path or an iterable of paths.") from error
+
+
+def _validated_source_options(source_options):
+ if source_options is None:
+ return {}
+ if not isinstance(source_options, Mapping):
+ raise ValueError("source_options must be a mapping.")
+ return dict(source_options)
+
+
+def _validate_source_kerberos(paths, source_options):
+ source_principal = (
+ source_options.get("security.kerberos.login.principal")
+ or source_options.get("security.principal")
+ )
+ source_keytab = (
+ source_options.get("security.kerberos.login.keytab")
+ or source_options.get("security.keytab")
+ )
+ if not source_principal and not source_keytab:
+ return
+ if bool(source_principal) != bool(source_keytab):
+ raise ValueError(
+ "Source Kerberos principal and keytab must be both set or both "
+ "unset.")
+ if not any(
+ urlparse(_source_path_text(path)).scheme.lower()
+ in ("hdfs", "viewfs") for path in paths):
+ return
+ raise ValueError(
+ "HDF5 sources cannot use an explicit Kerberos keytab in a shared "
+ "process because kinit overwrites process-global credentials. "
+ "Run the load in a process-isolated worker with a pre-acquired "
+ "ticket cache and omit the source principal and keytab options.")
+
+
+def _require_seekable(stream, source):
+ required = ("read", "seek", "tell")
+ if any(not callable(getattr(stream, method, None)) for method in required):
+ raise ValueError(
+ "HDF5 source stream must be seekable: %s" % source.path)
+ seekable = getattr(stream, "seekable", None)
+ if callable(seekable) and not seekable():
+ raise ValueError(
+ "HDF5 source stream must be seekable: %s" % source.path)
+ try:
+ stream.seek(stream.tell())
+ except (OSError, TypeError, ValueError) as error:
+ raise ValueError(
+ "HDF5 source stream must be seekable: %s" % source.path
+ ) from error
+
+
+def _strict_arrow_table(data, target_schema, source, batch_index):
+ if isinstance(data, pa.RecordBatch):
+ table = pa.Table.from_batches([data])
+ elif isinstance(data, pa.Table):
+ table = data
+ else:
+ raise ValueError(
+ "HDF5 transform must return Arrow data or an iterable of Arrow
data.")
+
+ missing = [
+ name for name in target_schema.names if name not in table.column_names
+ ]
+ if missing:
+ raise ValueError(
+ "HDF5 batch %d from %s is missing columns: %s"
+ % (batch_index, source.path, missing))
+ extra = [
+ name for name in table.column_names if name not in target_schema.names
+ ]
+ if extra:
+ raise ValueError(
+ "HDF5 batch %d from %s has unexpected columns: %s"
+ % (batch_index, source.path, extra))
+ if table.column_names != target_schema.names:
+ raise ValueError(
+ "HDF5 batch %d from %s has columns in the wrong order: %s; "
+ "expected %s."
+ % (batch_index, source.path, table.column_names,
+ target_schema.names))
+ try:
+ _validate_nested_nullability(table, target_schema)
+ if table.schema.equals(target_schema, check_metadata=False):
+ return table
+ casted = table.cast(target_schema, safe=True)
+ _validate_nested_nullability(casted, target_schema)
+ return casted
+ except (ValueError, TypeError, NotImplementedError) as error:
+ raise ValueError(
+ "HDF5 batch %d from %s cannot be converted to the table schema: %s"
+ % (batch_index, source.path, error)) from error
+
+
+def _validate_nested_nullability(table, schema):
+ for field, column in zip(schema, table.columns):
+ for chunk in column.chunks:
+ _validate_array_nullability(chunk, field, field.name)
+
+
+def _validate_array_nullability(array, field, path):
+ if not field.nullable and array.null_count:
+ raise ValueError(
+ "non-nullable field %s contains %d null value(s)"
+ % (path, array.null_count))
+
+ target_type = field.type
+ source_type = array.type
+ if (pa.types.is_list(target_type)
+ or pa.types.is_large_list(target_type)
+ or pa.types.is_fixed_size_list(target_type)):
+ if not (pa.types.is_list(source_type)
+ or pa.types.is_large_list(source_type)
+ or pa.types.is_fixed_size_list(source_type)):
+ return
+ _validate_array_nullability(
+ pc.list_flatten(array),
+ target_type.value_field,
+ "%s.%s" % (path, target_type.value_field.name),
+ )
+ return
+
+ if pa.types.is_map(target_type):
+ if not pa.types.is_map(source_type):
+ return
+ start = array.offsets[0].as_py()
+ stop = array.offsets[-1].as_py()
+ length = stop - start
+ offsets = pc.subtract(
+ array.offsets,
+ pa.scalar(start, type=array.offsets.type),
+ )
+ entries = pa.StructArray.from_arrays(
+ [array.keys.slice(start, length),
+ array.items.slice(start, length)],
+ fields=[source_type.key_field, source_type.item_field],
+ )
+ logical_entries = pc.list_flatten(pa.ListArray.from_arrays(
+ offsets,
+ entries,
+ mask=pc.is_null(array),
+ ))
+ _validate_array_nullability(
+ logical_entries.field(0), target_type.key_field,
+ "%s.%s" % (path, target_type.key_field.name))
+ _validate_array_nullability(
+ logical_entries.field(1), target_type.item_field,
+ "%s.%s" % (path, target_type.item_field.name))
+ return
+
+ if pa.types.is_struct(target_type):
+ if not pa.types.is_struct(source_type):
+ return
+ parent_valid = pc.is_valid(array) if array.null_count else None
+ for index, child_field in enumerate(target_type):
+ child = array.field(index)
+ if parent_valid is not None:
+ child = pc.filter(child, parent_valid)
+ _validate_array_nullability(
+ child,
+ child_field,
+ "%s.%s" % (path, child_field.name),
+ )
+
+
+def _arrow_batches(transformed):
+ if isinstance(transformed, (pa.Table, pa.RecordBatch)):
+ return iter([transformed])
+ if transformed is None or isinstance(transformed, (str, bytes, dict)):
+ raise ValueError(
+ "HDF5 transform must return Arrow data or an iterable of Arrow
data.")
+ try:
+ return iter(transformed)
+ except TypeError as error:
+ raise ValueError(
+ "HDF5 transform must return Arrow data or an iterable of Arrow
data."
+ ) from error
+
+
+def _close_transform_iterator(iterator):
+ close = getattr(iterator, "close", None)
+ if close is not None:
+ close()
diff --git a/paimon-python/pypaimon/tests/file_io_test.py
b/paimon-python/pypaimon/tests/file_io_test.py
index f261b0a73b..9803a53790 100644
--- a/paimon-python/pypaimon/tests/file_io_test.py
+++ b/paimon-python/pypaimon/tests/file_io_test.py
@@ -21,13 +21,14 @@ import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
+from urllib.parse import urlparse
import pyarrow.fs as pafs
from pypaimon.common.file_io import create_temp_path
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions
-from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.filesystem.local_file_io import LocalFileIO, _file_uri_path
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO, _pyarrow_lt_7
@@ -54,6 +55,22 @@ class FileIOTest(unittest.TestCase):
# Test bucket and path
self.assertEqual(file_io.to_filesystem_path("s3://my-bucket/path/to/file.txt"),
"my-bucket/path/to/file.txt")
+ self.assertEqual(
+ file_io.to_filesystem_path(
+ "s3://my-bucket/path/episode%23copy%3F1.h5"),
+ "my-bucket/path/episode%23copy%3F1.h5",
+ )
+ self.assertEqual(
+ file_io.to_filesystem_path(
+ "s3://my-bucket/path/episode%2523copy.h5"),
+ "my-bucket/path/episode%2523copy.h5",
+ )
+ self.assertEqual(
+ file_io.to_filesystem_path(
+ "s3://my-bucket/t/p=a%2Fb/data.parquet"),
+ "my-bucket/t/p=a%2Fb/data.parquet",
+ )
+
self.assertEqual(file_io.to_filesystem_path("oss://my-bucket/path/to/file.txt"),
"my-bucket/path/to/file.txt")
@@ -167,6 +184,11 @@ class FileIOTest(unittest.TestCase):
"/tmp/path/to/file.txt")
self.assertEqual(file_io.to_filesystem_path("file:///path/to/file.txt"),
"/path/to/file.txt")
+ self.assertEqual(
+ file_io.to_filesystem_path(
+ "file:///tmp/episode%20%E4%B8%AD%E6%96%87.h5"),
+ "/tmp/episode 中文.h5",
+ )
# Test empty paths
self.assertEqual(file_io.to_filesystem_path("file://"), ".")
@@ -209,6 +231,25 @@ class FileIOTest(unittest.TestCase):
self.assertEqual(s3_file_io.to_filesystem_path("C:\\path\\to\\file.txt"),
"C:\\path\\to\\file.txt")
+ def test_standard_windows_and_unc_file_uris_round_trip(self):
+ self.assertEqual(
+ r"C:\data set\episode.h5",
+ _file_uri_path(
+ urlparse("file:///C:/data%20set/episode.h5"), windows=True),
+ )
+ self.assertEqual(
+ r"\\server\share\episode.h5",
+ _file_uri_path(
+ urlparse("file://server/share/episode.h5"), windows=True),
+ )
+
+ def test_local_directory_listing_propagates_permission_errors(self):
+ with tempfile.TemporaryDirectory() as directory:
+ file_io = LocalFileIO(directory, Options({}))
+ with patch.object(Path, "iterdir", side_effect=PermissionError):
+ with self.assertRaises(PermissionError):
+ file_io.list_status(directory)
+
def test_path_normalization(self):
"""Test path normalization (multiple slashes)."""
file_io = LocalFileIO("file:///tmp/warehouse", Options({}))
diff --git a/paimon-python/pypaimon/tests/hdfs_native_test.py
b/paimon-python/pypaimon/tests/hdfs_native_test.py
index 957ee8f123..ae8d7d673a 100644
--- a/paimon-python/pypaimon/tests/hdfs_native_test.py
+++ b/paimon-python/pypaimon/tests/hdfs_native_test.py
@@ -648,6 +648,14 @@ class ToFilesystemPathTest(unittest.TestCase):
"/foo/bar",
)
+ def test_hdfs_uri_preserves_paimon_escaped_partition_path(self):
+ fio = self._make("hdfs://ns1/")
+ self.assertEqual(
+ fio.to_filesystem_path(
+ "hdfs://ns1/warehouse/t/p=a%2Fb/data.parquet"),
+ "/warehouse/t/p=a%2Fb/data.parquet",
+ )
+
def test_hdfs_uri_different_ns_unchanged(self):
fio = self._make("hdfs://ns1/")
self.assertEqual(
diff --git a/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
b/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
new file mode 100644
index 0000000000..27063cefc2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
@@ -0,0 +1,949 @@
+# 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 os
+import shutil
+import sys
+import tempfile
+import unittest
+import warnings
+from dataclasses import fields
+from functools import partial
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pyarrow as pa
+import pyarrow.fs as pafs
+
+import pypaimon.multimodal as pmm
+from pypaimon.common.options import Options
+from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
+from pypaimon.multimodal.hdf5 import (
+ _Hdf5SourceFileIO,
+ _normalize_source_path,
+ _qualified_status_path,
+ _strict_arrow_table,
+ load_from_hdf5,
+)
+
+try:
+ import h5py
+except ImportError:
+ h5py = None
+
+
+_OPTIONS = {
+ "file.format": "parquet",
+ "vector.file.format": "parquet",
+ "blob-as-descriptor": "false",
+}
+
+
+class Hdf5RuntimeContractTest(unittest.TestCase):
+
+ def test_hdf5_load_requires_python_38_or_newer(self):
+ with patch(
+ "pypaimon.multimodal.hdf5.sys.version_info", (3, 7)):
+ with self.assertRaisesRegex(RuntimeError, "Python 3.8 or newer"):
+ load_from_hdf5(Mock(), [], transform=lambda h5, source: ())
+
+ def test_hdf5_source_io_decodes_only_its_external_uri(self):
+ backend = Mock()
+ backend.to_filesystem_path.return_value = (
+ "bucket/episode%23copy%3F1.h5")
+ backend.get_file_status.return_value = "status"
+ resolver = Mock()
+ resolver._get_fileio.return_value = backend
+
+ with patch(
+ "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ return_value=resolver):
+ source_io = _Hdf5SourceFileIO(Options({}))
+ self.assertEqual(
+ "status",
+ source_io.get_file_status(
+ "s3://bucket/episode%23copy%3F1.h5"),
+ )
+
+ backend.get_file_status.assert_called_once_with(
+ "bucket/episode#copy?1.h5")
+
+
+class _WriterProxy:
+
+ def __init__(self, delegate, write_error=None):
+ self.delegate = delegate
+ self.write_error = write_error
+ self.abort_count = 0
+ self.close_count = 0
+
+ def write_arrow(self, table):
+ if self.write_error is not None:
+ raise self.write_error
+ return self.delegate.write_arrow(table)
+
+ def prepare_commit(self):
+ return self.delegate.prepare_commit()
+
+ def abort(self):
+ self.abort_count += 1
+ return self.delegate.abort()
+
+ def close(self):
+ self.close_count += 1
+ return self.delegate.close()
+
+
+class _CommitterProxy:
+
+ def __init__(self, delegate, commit_error=None, raise_after_commit=False):
+ self.delegate = delegate
+ self.commit_error = commit_error
+ self.raise_after_commit = raise_after_commit
+ self.commit_count = 0
+ self.close_count = 0
+
+ def add_commit_callback(self, callback):
+ return self.delegate.add_commit_callback(callback)
+
+ def commit(self, messages):
+ self.commit_count += 1
+ if self.commit_error is not None and not self.raise_after_commit:
+ raise self.commit_error
+ result = self.delegate.commit(messages)
+ if self.commit_error is not None:
+ raise self.commit_error
+ return result
+
+ def close(self):
+ self.close_count += 1
+ return self.delegate.close()
+
+
+class _WriteBuilderProxy:
+
+ def __init__(self, writer, committer):
+ self.writer = writer
+ self.committer = committer
+
+ def new_write(self):
+ return self.writer
+
+ def new_commit(self):
+ return self.committer
+
+
+class _TrackedSourceStream(io.BytesIO):
+
+ def __init__(self, data, source_file_io):
+ super().__init__(data)
+ self.source_file_io = source_file_io
+
+ def seekable(self):
+ return self.source_file_io.seekable
+
+ def close(self):
+ if not self.closed:
+ self.source_file_io.closed_stream_count += 1
+ super().close()
+
+
+class _RemoteSourceFileIO:
+
+ def __init__(
+ self,
+ objects=None,
+ directories=None,
+ seekable=True,
+ native_stream=False):
+ self.objects = {
+ self._filesystem_path(path): value
+ for path, value in dict(objects or {}).items()
+ }
+ self.directories = {
+ self._filesystem_path(path): [
+ self._filesystem_path(child) for child in children]
+ for path, children in dict(directories or {}).items()
+ }
+ self.seekable = seekable
+ self.native_stream = native_stream
+ self.opened_paths = []
+ self.streams = []
+ self.closed_stream_count = 0
+ self.close_count = 0
+ self.list_error = None
+
+ @staticmethod
+ def _filesystem_path(path):
+ from urllib.parse import urlparse
+ parsed = urlparse(path)
+ return "%s%s" % (parsed.netloc, parsed.path)
+
+ def _status(self, path):
+ if path in self.objects:
+ file_type = pafs.FileType.File
+ elif path in self.directories:
+ file_type = pafs.FileType.Directory
+ else:
+ raise FileNotFoundError(path)
+ return pafs.FileInfo(path, file_type)
+
+ def _get_fileio(self, path):
+ return self
+
+ def to_filesystem_path(self, path):
+ return self._filesystem_path(path)
+
+ def get_file_status(self, path):
+ return self._status(path)
+
+ def list_status(self, path):
+ if self.list_error is not None:
+ raise self.list_error
+ return [self._status(child) for child in self.directories[path]]
+
+ def new_input_stream(self, path):
+ self.opened_paths.append(path)
+ if self.native_stream:
+ stream = pa.BufferReader(self.objects[path])
+ else:
+ stream = _TrackedSourceStream(self.objects[path], self)
+ self.streams.append(stream)
+ return stream
+
+ def close(self):
+ self.close_count += 1
+
+
[email protected](h5py is None, "h5py is not installed")
+class MultimodalHdf5Test(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp(prefix="pypaimon_hdf5_")
+ self.source_dir = Path(self.temp_dir) / "source"
+ self.warehouse = os.path.join(self.temp_dir, "warehouse")
+ self.source_dir.mkdir()
+ self.conn = pmm.connect(options={"warehouse": self.warehouse})
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def _write_source(self, relative_path, offset=0):
+ path = self.source_dir / relative_path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with h5py.File(path, "w") as h5:
+ h5.create_dataset(
+ "values",
+ data=np.arange(
+ offset, offset + 12, dtype=np.float32).reshape(4, 3),
+ )
+ images = h5.create_dataset(
+ "images", (4,), dtype=h5py.vlen_dtype(np.dtype("uint8")))
+ for index in range(4):
+ jpeg = (b"\xff\xd8\xff" +
+ ("%s-%d" % (path.name, index)).encode("utf-8") +
+ b"\xff\xd9")
+ images[index] = np.frombuffer(
+ jpeg, dtype=np.uint8)
+ return path
+
+ @staticmethod
+ def _schema():
+ return pa.schema([
+ pa.field("source", pa.string(), nullable=False),
+ pa.field("frame_index", pa.int32(), nullable=False),
+ pa.field("score", pa.float64()),
+ pa.field("value", pa.list_(pa.float32(), 3), nullable=False),
+ pa.field("image", pa.large_binary()),
+ ])
+
+ def _create_table(self, name="frames"):
+ return self.conn.create_table(
+ name, schema=self._schema(), options=_OPTIONS)
+
+ def _load(self, table, paths, *, transform, source_options=None):
+ with patch.object(self.conn, "get_table", return_value=table):
+ return self.conn.load_from_hdf5(
+ table.identifier,
+ paths,
+ transform=transform,
+ source_options=source_options,
+ )
+
+ @staticmethod
+ def _transform(h5, source):
+ values = np.asarray(h5["values"][:], dtype=np.float32)
+ images = [
+ np.asarray(value, dtype=np.uint8).tobytes()
+ for value in h5["images"][:]
+ ]
+ for begin in range(0, len(values), 2):
+ end = min(begin + 2, len(values))
+ # Deliberately let Arrow infer int64, list<double>, and binary.
+ # HDF5 loading may safely cast after validating exact columns.
+ yield pa.Table.from_pydict({
+ "source": [source.name] * (end - begin),
+ "frame_index": list(range(begin, end)),
+ "score": [None if index == 0 else float(index)
+ for index in range(begin, end)],
+ "value": values[begin:end].tolist(),
+ "image": images[begin:end],
+ })
+
+ def _instrument_write(
+ self,
+ table,
+ *,
+ write_error=None,
+ commit_error=None,
+ raise_after_commit=False):
+ builder = table.raw_table.new_batch_write_builder()
+ writer = _WriterProxy(builder.new_write(), write_error=write_error)
+ committer = _CommitterProxy(
+ builder.new_commit(),
+ commit_error=commit_error,
+ raise_after_commit=raise_after_commit,
+ )
+ proxy = _WriteBuilderProxy(writer, committer)
+ return proxy, writer, committer
+
+ def test_load_from_hdf5_streams_multiple_files_in_one_snapshot(self):
+ first = self._write_source("a.hdf5", 0)
+ self._write_source("nested/b.h5", 100)
+ table = self._create_table()
+ table.add(pa.Table.from_pydict({
+ "source": ["seed"],
+ "frame_index": [-1],
+ "score": [None],
+ "value": [[-1.0, -1.0, -1.0]],
+ "image": [b"seed"],
+ }, schema=self._schema()))
+ before_snapshot =
table.raw_table.snapshot_manager().get_latest_snapshot()
+ seen = []
+ seen_paths = []
+
+ def transform(h5, source):
+ seen.append(source.name)
+ seen_paths.append(source.path)
+ yield from self._transform(h5, source)
+
+ result = self._load(
+ table,
+ [self.source_dir, first, first.resolve()],
+ transform=transform,
+ )
+
+ self.assertEqual(2, result.file_count)
+ self.assertEqual(4, result.batch_count)
+ self.assertEqual(8, result.row_count)
+ self.assertEqual(before_snapshot.id + 1, result.snapshot_id)
+ self.assertEqual(
+ result.snapshot_id,
+ table.raw_table.snapshot_manager().get_latest_snapshot().id,
+ )
+ self.assertEqual(["a.hdf5", "b.h5"], seen)
+ self.assertTrue(all(path.startswith("file://") for path in seen_paths))
+ rows = table.scan().select([
+ "source", "frame_index", "score", "value", "image",
+ ]).to_arrow().to_pylist()
+ self.assertEqual(9, len(rows))
+ self.assertEqual({"seed", "a.hdf5", "b.h5"}, {
+ row["source"] for row in rows
+ })
+ self.assertTrue(any(row["score"] is None for row in rows))
+ self.assertTrue(all(
+ row["image"] is None or isinstance(row["image"], bytes)
+ for row in rows
+ ))
+ self.assertTrue(all(
+ row["image"].startswith(b"\xff\xd8\xff")
+ and row["image"].endswith(b"\xff\xd9")
+ for row in rows if row["source"] != "seed"
+ ))
+ self.assertEqual(3, len(rows[-1]["value"]))
+
+ def test_load_from_hdf5_accepts_paths_and_reappends(self):
+ first = self._write_source("a.hdf5", 0)
+ self._write_source("nested/b.h5", 100)
+ table = self._create_table()
+
+ single = self._load(table, first, transform=self._transform)
+ duplicate_list = self._load(
+ table,
+ [first, first.resolve(), first.resolve().as_uri()],
+ transform=self._transform,
+ )
+ directory = self._load(
+ table, self.source_dir, transform=self._transform)
+
+ self.assertEqual((1, 2, 4), (
+ single.file_count, single.batch_count, single.row_count))
+ self.assertEqual((1, 2, 4), (
+ duplicate_list.file_count,
+ duplicate_list.batch_count,
+ duplicate_list.row_count,
+ ))
+ self.assertEqual((2, 4, 8), (
+ directory.file_count, directory.batch_count, directory.row_count))
+ self.assertEqual(16, table.scan().to_arrow().num_rows)
+ self.assertEqual(
+ ["file_count", "batch_count", "row_count", "snapshot_id"],
+ [field.name for field in fields(type(directory))],
+ )
+ self.assertEqual(["path"], [
+ field.name for field in fields(pmm.Hdf5File)
+ ])
+
+ def test_hdf5_file_exposes_decoded_local_path(self):
+ source = self._write_source("episode 中文.h5")
+ table = self._create_table("local_context")
+ seen = []
+
+ def transform(h5, context):
+ seen.append(context)
+ yield from self._transform(h5, context)
+
+ self._load(table, source, transform=transform)
+
+ self.assertEqual(source.resolve(), seen[0].local_path)
+ self.assertEqual("episode 中文.h5", seen[0].name)
+ self.assertEqual("episode 中文", seen[0].stem)
+ self.assertIsNone(
+ pmm.Hdf5File("s3://source-bucket/episode.h5").local_path)
+
+ expected = source.resolve()
+ context = pmm.Hdf5File(expected.as_uri())
+ with patch.object(Path, "resolve", side_effect=AssertionError):
+ self.assertEqual(expected, context.local_path)
+
+ def test_windows_drive_paths_are_normalized_as_file_uris(self):
+ path = r"C:\data set\episode.h5"
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", DeprecationWarning)
+ self.assertEqual(
+ "file:///C:/data%20set/episode.h5",
+ _normalize_source_path(path),
+ )
+
+ def test_qualified_status_path_covers_fileio_path_shapes(self):
+ cases = (
+ ("s3://bucket/root", "s3://bucket/root/a.h5",
+ "s3://bucket/root/a.h5"),
+ ("file:///tmp/root", "/tmp/root/a.h5",
+ Path("/tmp/root/a.h5").resolve().as_uri()),
+ ("file:///C:/root", r"C:\root\a.h5",
+ "file:///C:/root/a.h5"),
+ ("hdfs://namenode:8020/root", "/root/a.h5",
+ "hdfs://namenode:8020/root/a.h5"),
+ ("viewfs://cluster/root", "root/a.h5",
+ "viewfs://cluster/root/a.h5"),
+ ("s3://bucket/root", "bucket/root/a.h5",
+ "s3://bucket/root/a.h5"),
+ ("s3://bucket/root", "root/a.h5",
+ "s3://bucket/root/a.h5"),
+ ("oss://bucket/root", "bucket/root/a.h5",
+ "oss://bucket/root/a.h5"),
+ ("gs://bucket/root", "root/a.h5",
+ "gs://bucket/root/a.h5"),
+ ("s3://bucket/root", "bucket/root/episode#backup.h5",
+ "s3://bucket/root/episode%23backup.h5"),
+ ("hdfs://namenode/root", "/root/episode?copy.hdf5",
+ "hdfs://namenode/root/episode%3Fcopy.hdf5"),
+ ("s3://bucket/root", "bucket/root/episode%23copy.h5",
+ "s3://bucket/root/episode%2523copy.h5"),
+ )
+ for parent, status_path, expected in cases:
+ with self.subTest(parent=parent, status_path=status_path):
+ status = pafs.FileInfo(status_path, pafs.FileType.File)
+ self.assertEqual(
+ expected, _qualified_status_path(parent, status))
+
+ def test_load_from_hdf5_reads_recursive_remote_sources(self):
+ root = "s3://source-bucket/episodes"
+ first = root + "/a.hdf5"
+ nested = root + "/nested"
+ second = nested + "/b.h5"
+ source_file_io = _RemoteSourceFileIO(
+ objects={
+ first: self._write_source("remote-a.hdf5", 0).read_bytes(),
+ second: self._write_source("remote-b.h5", 100).read_bytes(),
+ },
+ directories={
+ root: [nested, first],
+ nested: [second],
+ },
+ native_stream=True,
+ )
+ source_options = {
+ "fs.s3.endpoint": "https://source.example.com",
+ "fs.s3.access-key": "source-key",
+ }
+ table = self._create_table("remote_frames")
+ seen = []
+
+ def transform(h5, source):
+ seen.append((source.path, source.name, source.stem))
+ yield from self._transform(h5, source)
+
+ with patch(
+ "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ return_value=source_file_io) as resolving_file_io:
+ result = self._load(
+ table,
+ [root, first],
+ transform=transform,
+ source_options=source_options,
+ )
+
+ self.assertEqual((2, 4, 8), (
+ result.file_count, result.batch_count, result.row_count))
+ self.assertEqual([
+ "source-bucket/episodes/a.hdf5",
+ "source-bucket/episodes/nested/b.h5",
+ ], source_file_io.opened_paths)
+ self.assertEqual([
+ (first, "a.hdf5", "a"),
+ (second, "b.h5", "b"),
+ ], seen)
+ self.assertTrue(all(stream.closed for stream in
source_file_io.streams))
+ self.assertEqual(1, source_file_io.close_count)
+ actual_options = resolving_file_io.call_args.args[0].to_map()
+ self.assertEqual(source_options, actual_options)
+ self.assertEqual(
+ result.snapshot_id,
+ table.raw_table.snapshot_manager().get_latest_snapshot().id,
+ )
+
+ def test_non_seekable_remote_stream_aborts_and_closes_resources(self):
+ source = "s3://source-bucket/episode.h5"
+ source_file_io = _RemoteSourceFileIO(
+ objects={
+ source: self._write_source("remote.h5").read_bytes(),
+ },
+ seekable=False,
+ )
+ table = self._create_table("non_seekable")
+ proxy, writer, committer = self._instrument_write(table)
+
+ with patch(
+ "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ return_value=source_file_io), patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ return_value=proxy):
+ with self.assertRaisesRegex(ValueError, "seekable"):
+ self._load(table, source, transform=self._transform)
+
+ self.assertEqual(1, writer.abort_count)
+ self.assertEqual(1, writer.close_count)
+ self.assertEqual(1, committer.close_count)
+ self.assertEqual(1, source_file_io.closed_stream_count)
+ self.assertEqual(1, source_file_io.close_count)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_legacy_oss_directory_error_recommends_explicit_files(self):
+ source = "oss://source-bucket/episodes"
+ source_file_io = _RemoteSourceFileIO()
+ source_file_io.list_error = LegacyOssDirectoryListingError(
+ "Listing OSS directories is not supported with PyArrow < 16")
+ table = self._create_table("legacy_oss")
+ new_builder = Mock()
+
+ with patch(
+ "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ return_value=source_file_io), patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ new_builder):
+ with self.assertRaisesRegex(
+ ValueError, "pass explicit HDF5 file paths"):
+ self._load(table, source, transform=self._transform)
+
+ new_builder.assert_not_called()
+ self.assertEqual(1, source_file_io.close_count)
+
+ def test_load_from_hdf5_rejects_non_arrow_and_empty_output(self):
+ source = self._write_source("episode.h5")
+ cases = (
+ (lambda h5, info: {"frame_index": [0]}, "Arrow data"),
+ (lambda h5, info: iter(()), "produced no rows"),
+ )
+ for index, (transform, message) in enumerate(cases):
+ table = self._create_table("invalid_output_%d" % index)
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ValueError, message):
+ self._load(table, source, transform=transform)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_each_discovered_file_must_produce_rows(self):
+ valid = self._write_source("valid.h5")
+ empty = self._write_source("empty.h5")
+ table = self._create_table("per_source_rows")
+ proxy, writer, committer = self._instrument_write(table)
+
+ def transform(h5, source):
+ if source.name == "empty.h5":
+ return iter(())
+ return self._transform(h5, source)
+
+ with patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ return_value=proxy):
+ with self.assertRaisesRegex(
+ ValueError, "empty.h5.*produced no rows"):
+ self._load(table, [valid, empty], transform=transform)
+
+ self.assertEqual(1, writer.abort_count)
+ self.assertEqual(0, committer.commit_count)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_strict_schema_rejects_nested_non_nullable_values(self):
+ source = pmm.Hdf5File("file:///tmp/episode.h5")
+ cases = (
+ (
+ pa.field(
+ "items",
+ pa.list_(pa.field("item", pa.int32(), nullable=False)),
+ ),
+ [[1, None]],
+ "items.item",
+ ),
+ (
+ pa.field(
+ "vector",
+ pa.list_(
+ pa.field("item", pa.float32(), nullable=False), 2),
+ ),
+ [[1.0, None]],
+ "vector.item",
+ ),
+ (
+ pa.field(
+ "attributes",
+ pa.map_(
+ pa.string(),
+ pa.field("value", pa.int32(), nullable=False),
+ ),
+ ),
+ [[("key", None)]],
+ "attributes.value",
+ ),
+ )
+
+ for field, values, path in cases:
+ schema = pa.schema([field])
+ batch = pa.Table.from_arrays(
+ [pa.array(values, type=field.type)], schema=schema)
+ with self.subTest(path=path):
+ with self.assertRaisesRegex(ValueError, path):
+ _strict_arrow_table(batch, schema, source, 0)
+
+ def test_orc_load_rejects_null_array_elements_and_map_values(self):
+ source = self._write_source("nested_nulls.h5")
+ cases = (
+ (
+ "orc_array_null",
+ pa.field(
+ "nested",
+ pa.list_(pa.field("item", pa.int32(), nullable=False)),
+ ),
+ [[1, None]],
+ ),
+ (
+ "orc_map_null",
+ pa.field(
+ "nested",
+ pa.map_(
+ pa.string(),
+ pa.field("value", pa.int32(), nullable=False),
+ ),
+ ),
+ [[("key", None)]],
+ ),
+ )
+ for name, nested_field, values in cases:
+ schema = pa.schema([
+ pa.field("id", pa.int32(), nullable=False),
+ nested_field,
+ ])
+ table = self.conn.create_table(
+ name,
+ schema=schema,
+ options={
+ "file.format": "orc",
+ "blob-as-descriptor": "false",
+ },
+ )
+
+ def transform(h5, context, field=nested_field, data=values):
+ return pa.Table.from_arrays(
+ [pa.array([1], type=pa.int32()),
+ pa.array(data, type=field.type)],
+ names=["id", "nested"],
+ )
+
+ with self.subTest(name=name):
+ with self.assertRaisesRegex(ValueError, "nested"):
+ self._load(table, source, transform=transform)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_nested_nullability_checks_only_logical_sliced_map_values(self):
+ item_field = pa.field("value", pa.int32(), nullable=False)
+ map_type = pa.map_(pa.string(), item_field)
+ schema = pa.schema([pa.field("attributes", map_type)])
+ values = pa.array(
+ [[("outside", None)], [("inside", 1)]], type=map_type)
+ sliced = pa.Table.from_arrays(
+ [values.slice(1, 1)], schema=schema)
+
+ result = _strict_arrow_table(
+ sliced, schema, pmm.Hdf5File("file:///tmp/episode.h5"), 0)
+
+ self.assertEqual(
+ [[("inside", 1)]], result.column("attributes").to_pylist())
+
+ def test_nested_nullability_ignores_entries_hidden_by_null_maps(self):
+ item_field = pa.field("value", pa.int32(), nullable=False)
+ map_type = pa.map_(pa.string(), item_field)
+ schema = pa.schema([pa.field("attributes", map_type)])
+ offsets = pa.array([0, 1, 2], type=pa.int32())
+ entries = pa.StructArray.from_arrays(
+ [
+ pa.array(["hidden", "visible"]),
+ pa.array([None, 1], type=pa.int32()),
+ ],
+ fields=[map_type.key_field, map_type.item_field],
+ )
+ validity = pa.array([False, True], type=pa.bool_()).buffers()[1]
+ values = pa.MapArray.from_buffers(
+ map_type,
+ 2,
+ [validity, offsets.buffers()[1]],
+ children=[entries],
+ )
+ table = pa.Table.from_arrays([values], schema=schema)
+
+ result = _strict_arrow_table(
+ table, schema, pmm.Hdf5File("file:///tmp/episode.h5"), 0)
+
+ self.assertEqual(
+ [None, [("visible", 1)]],
+ result.column("attributes").to_pylist(),
+ )
+
+ def test_load_from_hdf5_rejects_invalid_schemas(self):
+ source = self._write_source("episode.hdf5")
+ good = {
+ "source": ["episode.hdf5"],
+ "frame_index": [0],
+ "score": [None],
+ "value": [[1.0, 2.0, 3.0]],
+ "image": [b"jpeg"],
+ }
+ cases = []
+ missing = dict(good)
+ missing.pop("score")
+ cases.append((missing, "missing columns"))
+ extra = dict(good, unexpected=[1])
+ cases.append((extra, "unexpected columns"))
+ incompatible = dict(good, frame_index=["not-an-int"])
+ cases.append((incompatible, "cannot be converted"))
+ invalid_vector = dict(good, value=[[1.0, 2.0]])
+ cases.append((invalid_vector, "cannot be converted"))
+ null_non_nullable = dict(good, source=[None])
+ cases.append((null_non_nullable, "cannot be converted"))
+
+ for index, (data, message) in enumerate(cases):
+ table = self._create_table("strict_%d" % index)
+
+ def transform(h5, info, batch=data):
+ yield pa.Table.from_pydict(batch)
+
+ with self.subTest(message=message):
+ with self.assertRaisesRegex(ValueError, message):
+ self._load(table, source, transform=transform)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_hdfs_source_rejects_explicit_keytab_for_any_target(self):
+ table = self._create_table("kerberos_isolation")
+
+ with patch.object(
+ self.conn, "get_table", return_value=table), patch(
+ "pypaimon.multimodal.hdf5.ResolvingFileIO") as resolving:
+ with self.assertRaisesRegex(
+ ValueError, "process-isolated"):
+ self.conn.load_from_hdf5(
+ table.identifier,
+ "hdfs://source-ns/episode.h5",
+ transform=self._transform,
+ source_options={
+ "security.kerberos.login.principal": "source@REALM",
+ "security.kerberos.login.keytab": "/source.keytab",
+ },
+ )
+
+ resolving.assert_not_called()
+
+ def test_precommit_failures_abort_and_close_all_open_resources(self):
+ valid_source = self._write_source("valid.h5")
+ invalid_source = self.source_dir / "invalid.h5"
+ invalid_source.write_bytes(b"not hdf5")
+
+ def transform_failure(h5, source):
+ yield next(self._transform(h5, source))
+ raise RuntimeError("transform failed")
+
+ def schema_failure(h5, source, resource_state):
+ try:
+ yield pa.Table.from_pydict({"missing": [1]})
+ raise AssertionError("generator should have been closed")
+ finally:
+ resource_state["generator_closed"] = True
+
+ cases = (
+ (invalid_source, self._transform, None, OSError),
+ (valid_source, transform_failure, None, RuntimeError),
+ (valid_source, schema_failure, None, ValueError),
+ (valid_source, self._transform, RuntimeError("write failed"),
RuntimeError),
+ )
+ for index, (source, transform, write_error, error_type) in
enumerate(cases):
+ table = self._create_table("failure_%d" % index)
+ proxy, writer, committer = self._instrument_write(
+ table, write_error=write_error)
+ resource_state = {}
+ expects_generator_close = transform is schema_failure
+ if expects_generator_close:
+ transform = partial(
+ schema_failure, resource_state=resource_state)
+
+ def tracked_transform(h5, info, delegate=transform):
+ resource_state["h5"] = h5
+ return delegate(h5, info)
+
+ with self.subTest(error=error_type.__name__):
+ with patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ return_value=proxy):
+ with self.assertRaises(error_type):
+ self._load(
+ table, source, transform=tracked_transform)
+ self.assertEqual(1, writer.abort_count)
+ self.assertEqual(1, writer.close_count)
+ self.assertEqual(1, committer.close_count)
+ self.assertEqual(0, committer.commit_count)
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+ if "h5" in resource_state:
+ self.assertFalse(resource_state["h5"].id.valid)
+ if expects_generator_close:
+ self.assertTrue(resource_state.get("generator_closed"))
+
+ def test_empty_discovery_is_a_noop_without_creating_a_writer(self):
+ table = self._create_table()
+ new_builder = Mock()
+
+ with patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ new_builder), patch.dict(sys.modules, {"h5py": None}):
+ for paths in ([], self.source_dir):
+ with self.subTest(paths=paths):
+ result = self._load(
+ table, paths, transform=self._transform)
+ self.assertEqual(
+ (0, 0, 0, None),
+ (
+ result.file_count,
+ result.batch_count,
+ result.row_count,
+ result.snapshot_id,
+ ),
+ )
+
+ new_builder.assert_not_called()
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ def test_discovery_failure_happens_before_writer_creation(self):
+ table = self._create_table()
+ new_builder = Mock()
+ missing = self.source_dir / "missing.h5"
+ with patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ new_builder):
+ with self.assertRaisesRegex(
+ ValueError,
+ "HDF5 path does not exist: %s" % missing):
+ self._load(table, missing, transform=self._transform)
+ new_builder.assert_not_called()
+
+ def test_existing_file_with_unsupported_suffix_reports_suffix_only(self):
+ source = self.source_dir / "episode.txt"
+ source.write_text("not hdf5", encoding="utf-8")
+ table = self._create_table("unsupported_suffix")
+
+ with self.assertRaisesRegex(
+ ValueError,
+ "HDF5 file has unsupported suffix: %s" % source):
+ self._load(table, source, transform=self._transform)
+
+ def test_commit_exception_is_not_retried_or_aborted(self):
+ source = self._write_source("episode.h5")
+ table = self._create_table()
+ commit_error = RuntimeError("unknown commit result")
+ proxy, writer, committer = self._instrument_write(
+ table,
+ commit_error=commit_error,
+ raise_after_commit=True,
+ )
+
+ with patch.object(
+ table.raw_table,
+ "new_batch_write_builder",
+ return_value=proxy):
+ with self.assertRaisesRegex(RuntimeError, "unknown commit result"):
+ self._load(table, source, transform=self._transform)
+
+ self.assertEqual(1, committer.commit_count)
+ self.assertEqual(0, writer.abort_count)
+ self.assertEqual(1, writer.close_count)
+ self.assertEqual(1, committer.close_count)
+ self.assertEqual(4, table.scan().to_arrow().num_rows)
+ self.assertEqual(
+ 1, table.raw_table.snapshot_manager().get_latest_snapshot().id)
+
+ def test_hdf5_api_is_connection_only_and_has_no_managed_provenance(self):
+ table = self._create_table()
+ self.assertTrue(hasattr(self.conn, "load_from_hdf5"))
+ self.assertFalse(hasattr(table, "append_hdf5"))
+ self.assertFalse(hasattr(table, "from_hdf5"))
+ self.assertFalse(hasattr(pmm, "HDF5_SOURCE_PATH_COLUMN"))
+ self.assertFalse(hasattr(pmm, "HDF5_SOURCE_SHA256_COLUMN"))
+ self.assertFalse(hasattr(pmm, "Hdf5SourceDriftError"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
b/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
index 02f71f1af8..43c4972f0c 100644
--- a/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
+++ b/paimon-python/pypaimon/tests/oss_legacy_mode_test.py
@@ -28,7 +28,10 @@ import pyarrow.fs as pafs
from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions
-from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
+from pypaimon.filesystem.pyarrow_file_io import (
+ LegacyOssDirectoryListingError,
+ PyArrowFileIO,
+)
TABLE_PATH = "oss://test-bucket/db-uuid.db/tbl-uuid"
@@ -235,7 +238,7 @@ class OssLegacyModeTest(unittest.TestCase):
"""Fail fast instead of the misleading raw NoSuchKey selector error."""
file_io = self._new_file_io(legacy=True)
- with self.assertRaises(RuntimeError) as ctx:
+ with self.assertRaises(LegacyOssDirectoryListingError) as ctx:
file_io.list_status(TABLE_PATH)
self.assertIn("pyarrow >= 16", str(ctx.exception))
file_io.filesystem.get_file_info.assert_not_called()
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index d864d5ce44..bf83fe4386 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -233,6 +233,10 @@ setup(
],
},
extras_require={
+ 'hdf5': [
+ # HDF5 loading is explicitly guarded and documented as Python 3.8+.
+ 'h5py>=3,<4; python_version>="3.8"',
+ ],
'ray': [
'ray>=2.10,<3; python_version>="3.8"',
],