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 606f230631 [python] Add ROSBag ingestion support (#9530)
606f230631 is described below
commit 606f230631e59500e35bd2975a73a3ac650dfba4
Author: Yann Byron <[email protected]>
AuthorDate: Wed Sep 2 14:39:35 2026 +0800
[python] Add ROSBag ingestion support (#9530)
---
.github/workflows/paimon-python-checks.yml | 4 +
docs/docs/pypaimon/multimodal-api.mdx | 93 +++
paimon-python/README.md | 63 ++
paimon-python/pypaimon/multimodal/__init__.py | 8 +
paimon-python/pypaimon/multimodal/connection.py | 24 +
paimon-python/pypaimon/multimodal/hdf5.py | 97 +--
.../pypaimon/multimodal/lerobot/source.py | 6 +-
.../pypaimon/multimodal/rosbag/__init__.py | 31 +
paimon-python/pypaimon/multimodal/rosbag/api.py | 160 ++++
paimon-python/pypaimon/multimodal/rosbag/loader.py | 377 +++++++++
paimon-python/pypaimon/multimodal/rosbag/source.py | 371 +++++++++
.../pypaimon/multimodal/rosbag/staging.py | 158 ++++
paimon-python/pypaimon/multimodal/source_utils.py | 93 ++-
paimon-python/pypaimon/ray/__init__.py | 2 +
paimon-python/pypaimon/ray/rosbag.py | 212 +++++
.../pypaimon/tests/multimodal_hdf5_test.py | 10 +-
.../pypaimon/tests/multimodal_lerobot_test.py | 8 +-
.../pypaimon/tests/multimodal_rosbag_test.py | 889 +++++++++++++++++++++
paimon-python/pypaimon/tests/ray_rosbag_test.py | 251 ++++++
paimon-python/setup.py | 4 +
20 files changed, 2757 insertions(+), 104 deletions(-)
diff --git a/.github/workflows/paimon-python-checks.yml
b/.github/workflows/paimon-python-checks.yml
index 53ad2cf2a3..b8ecc00913 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -139,6 +139,10 @@ jobs:
fi
python -m pip install 'h5py>=3,<4'
python -c "import h5py; print('h5py', h5py.__version__)"
+ if python -c "import sys; sys.exit(0 if sys.version_info >= (3,
10) else 1)"; then
+ python -m pip install './paimon-python[rosbag]'
+ python -c "import rosbags; print('rosbags installed')"
+ fi
if [[ "${{ matrix.python-version }}" == "3.10" ]]; then
python -m pip install './paimon-python[lerobot]'
python -c "import datasets, lerobot; print('datasets',
datasets.__version__, 'lerobot', lerobot.__version__)"
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index e8732875b1..5e6d352181 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -520,6 +520,99 @@ HDF5 core itself has no Ray dependency.
provenance columns, maintain a source ledger, skip prior inputs, or detect
source drift. Calling it again with the same input appends the rows again.
+## Load ROSBag
+
+`MultimodalConnection.load_from_rosbag` imports ROS1 `.bag`, ROS2 SQLite3 or
+MCAP recording directories, and standalone ROS2 `.mcap` files through a user
+Arrow transform. It requires Python 3.10 or newer:
+
+```shell
+pip install 'pypaimon[rosbag]'
+```
+
+```python
+import pyarrow as pa
+
+
+schema = pa.schema([
+ pa.field("source", pa.string(), nullable=False),
+ pa.field("timestamp", pa.int64(), nullable=False),
+ pa.field("value", pa.string(), nullable=False),
+])
+conn.create_table("messages", schema=schema)
+
+
+def transform(reader, source):
+ rows = []
+ for connection, timestamp, rawdata in reader.messages():
+ message = reader.deserialize(rawdata, connection.msgtype)
+ rows.append({
+ "source": source.name,
+ "timestamp": timestamp,
+ "value": message.data,
+ })
+ return pa.Table.from_pylist(rows)
+
+
+result = conn.load_from_rosbag(
+ "messages",
+ "/data/recordings",
+ transform=transform,
+)
+print(result.source_count, result.row_count, result.snapshot_id)
+```
+
+A transform receives an open `rosbags.highlevel.AnyReader` and a
+`RosbagSource`. The source exposes the original normalized `uri`, the temporary
+or original `local_path`, `format`, `name`, `stem`, and `is_remote`. Reader and
+local staging paths are valid only while the transform is running. A transform
+returns an Arrow table, record batch, or an iterable of either.
+
+Directories are searched recursively, but a directory containing
+`metadata.yaml` is one ROS2 logical source and is not searched below that
+point. Standalone `.db3` files are rejected because they may be one split of a
+larger recording. Set `allow_storage_fragment=True` only when importing exactly
+that SQLite storage fragment is intentional; the loader performs a SQLite
+integrity check but cannot prove that other recording splits are absent.
+
+Local paths and `file://`, `oss://`, `s3://`, `hdfs://`, `viewfs://`, and
+`gs://` URIs use PyPaimon FileIO. Pass source credentials through
+`source_options`; target warehouse options are not inherited. Unlike HDF5,
+`rosbags` requires local paths, so remote files are copied in bounded chunks to
+an attempt-scoped temporary directory. ROS2 metadata member paths are checked
+for traversal and normalization collisions before copying.
+
+Before Paimon creates a writer, the loader:
+
+1. validates every source manifest and checks for active or recovery sidecars;
+2. scans every recording to EOF and compares declared and readable messages;
+3. executes each transform once and applies strict target Arrow schema checks;
+4. stores all validated output in a temporary Arrow IPC file.
+
+This front-loaded validation normally reads each recording twice and needs
+temporary disk capacity, but a source, transform, or schema failure does not
+create Paimon data files. Source size, modification time, and ROS2 directory
+members are compared during the call. These checks detect observable changes;
+without a versioned URI they are not a transactional snapshot of an object
+store directory.
+
+Use `RosbagStagingConfig` to choose the local temporary directory, reserve free
+space with `min_free_bytes`, cap serial temporary usage with `max_bytes`, and
+set the remote copy chunk size with `copy_buffer_bytes`. In Ray mode,
+`max_bytes` applies to each worker's raw ROSBag staging; Arrow output capacity
+is controlled by Ray object-store and spill settings.
+
+For Ray, install `pypaimon[ray,rosbag]` and call
+`pypaimon.ray.load_from_rosbag`. The driver discovers manifests, each worker
+materializes and validates complete sources locally, and the transformed Ray
+Dataset is explicitly `materialize()`d before `write_paimon`. Success uses one
+coordinator commit. Ray may retry a transform, so transforms must be
+deterministic and free of non-idempotent external side effects.
+
+Both APIs are append-only and not retry-safe after a commit exception. A
+successful call creates one snapshot; write-stage failures may leave
uncommitted
+orphan files for normal Paimon cleanup.
+
## Load LeRobot Dataset v3
`load_from_lerobot` imports a local directory, FileIO URI, or Hugging Face
diff --git a/paimon-python/README.md b/paimon-python/README.md
index a008dbe260..75ee07090d 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -131,6 +131,69 @@ 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.
+# ROSBag to multimodal tables
+
+ROSBag loading requires Python 3.10 or newer:
+
+```commandline
+pip install 'pypaimon[rosbag]'
+```
+
+Create the target table, then map ROS messages with a user transform:
+
+```python
+import pyarrow as pa
+
+
+schema = pa.schema([
+ pa.field("source", pa.string(), nullable=False),
+ pa.field("timestamp", pa.int64(), nullable=False),
+ pa.field("value", pa.string(), nullable=False),
+])
+connection.create_table("messages", schema=schema)
+
+
+def transform(reader, source):
+ rows = []
+ for connection, timestamp, rawdata in reader.messages():
+ message = reader.deserialize(rawdata, connection.msgtype)
+ rows.append({
+ "source": source.name,
+ "timestamp": timestamp,
+ "value": message.data,
+ })
+ return pa.Table.from_pylist(rows)
+
+result = connection.load_from_rosbag(
+ "messages",
+ "s3://robot-data/recordings",
+ transform=transform,
+ source_options={"fs.s3.endpoint": "https://s3.example.com"},
+)
+```
+
+ROS1 `.bag`, ROS2 SQLite3/MCAP directories, and standalone ROS2 `.mcap`
+files are supported. OSS, S3, HDFS, ViewFS, and GCS URI sources use FileIO
+and are copied in bounded chunks to a local temporary directory because
+`rosbags` requires local paths. Standalone `.db3` files are rejected by
+default; `allow_storage_fragment=True` imports the one SQLite fragment without
+claiming that the complete recording is present.
+
+Every source is scanned to EOF before its transform runs. Transform output is
+strictly checked against the target Arrow schema and stored in a temporary
+Arrow IPC file. Paimon writers are created only after every source passes, so
+source, transform, and schema errors do not create Paimon data files. This
+front-loaded validation reads each recording twice and requires temporary disk
+space. A successful call commits all sources in one snapshot.
+
+Ray uses the same validation contract. Install both extras and call
+`pypaimon.ray.load_from_rosbag`; transformed output is fully materialized in
+Ray before `write_paimon` starts:
+
+```commandline
+pip install 'pypaimon[ray,rosbag]'
+```
+
# HDFS without a local Hadoop install
`pypaimon` supports HDFS through a pure-protocol client based on
diff --git a/paimon-python/pypaimon/multimodal/__init__.py
b/paimon-python/pypaimon/multimodal/__init__.py
index b669267b41..53717d0155 100644
--- a/paimon-python/pypaimon/multimodal/__init__.py
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -29,6 +29,11 @@ from pypaimon.multimodal.hdf5 import (
Hdf5File,
Hdf5LoadResult,
)
+from pypaimon.multimodal.rosbag import (
+ RosbagLoadResult,
+ RosbagSource,
+ RosbagStagingConfig,
+)
from pypaimon.multimodal.table import (
MultimodalTable,
TextRoute,
@@ -56,6 +61,9 @@ __all__ = [
"NoSuchKey",
"ObjectInfo",
"PutObjectResult",
+ "RosbagLoadResult",
+ "RosbagSource",
+ "RosbagStagingConfig",
"TextRoute",
"VectorRoute",
"VideoFrameCollator",
diff --git a/paimon-python/pypaimon/multimodal/connection.py
b/paimon-python/pypaimon/multimodal/connection.py
index 3c55032b84..8f51cdaf97 100644
--- a/paimon-python/pypaimon/multimodal/connection.py
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -136,6 +136,30 @@ class MultimodalConnection:
source_options=source_options,
)
+ def load_from_rosbag(
+ self,
+ table_name: str,
+ paths,
+ *,
+ transform,
+ default_typestore=None,
+ typestore_factory=None,
+ source_options=None,
+ staging=None,
+ allow_storage_fragment: bool = False):
+ """Validate and append ROS1/ROS2 transforms in one commit."""
+ from pypaimon.multimodal.rosbag import load_from_rosbag
+ return load_from_rosbag(
+ self.get_table(table_name),
+ paths,
+ transform=transform,
+ default_typestore=default_typestore,
+ typestore_factory=typestore_factory,
+ source_options=source_options,
+ staging=staging,
+ allow_storage_fragment=allow_storage_fragment,
+ )
+
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
index 25583726cd..e050d72b67 100644
--- a/paimon-python/pypaimon/multimodal/hdf5.py
+++ b/paimon-python/pypaimon/multimodal/hdf5.py
@@ -17,13 +17,12 @@
"""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 pathlib import Path, PurePosixPath
from typing import Callable, Mapping, Optional
-from urllib.parse import quote, unquote, urlparse, urlunparse
+from urllib.parse import unquote, urlparse
import pyarrow as pa
import pyarrow.fs as pafs
@@ -31,9 +30,11 @@ 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.arrow_utils import strict_arrow_table
from pypaimon.multimodal.source_utils import (
+ _SourceFileIO,
+ _normalize_source_path,
+ _qualified_status_path,
_source_path_text,
_validated_source_options,
_validate_source_kerberos,
@@ -43,6 +44,7 @@ from pypaimon.write.commit_callback import CommitCallback
_HDF5_SUFFIXES = (".h5", ".hdf5")
+_Hdf5SourceFileIO = _SourceFileIO
@dataclass(frozen=True)
@@ -98,38 +100,6 @@ class _SnapshotRecorder(CommitCallback):
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 to_filesystem_path(self, path):
- return self._resolve(path)[1]
-
- def close(self):
- self._resolver.close()
-
-
def load_from_hdf5(
table,
paths,
@@ -384,61 +354,6 @@ def _raise_legacy_directory_listing_error(path, error):
% path) from error
-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
diff --git a/paimon-python/pypaimon/multimodal/lerobot/source.py
b/paimon-python/pypaimon/multimodal/lerobot/source.py
index eaf995d69e..8d71e3eecb 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/source.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/source.py
@@ -31,8 +31,8 @@ import pyarrow.parquet as pq
from pypaimon.common.options import Options
from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
-from pypaimon.multimodal.hdf5 import (
- _Hdf5SourceFileIO,
+from pypaimon.multimodal.source_utils import (
+ _SourceFileIO,
_normalize_source_path,
_qualified_status_path,
)
@@ -80,7 +80,7 @@ def _resolved_source(source, source_options):
return
source_uri = _normalize_source_path(value).rstrip("/")
- source_file_io = _Hdf5SourceFileIO(Options(source_options))
+ source_file_io = _SourceFileIO(Options(source_options))
try:
try:
status = source_file_io.get_file_status(source_uri)
diff --git a/paimon-python/pypaimon/multimodal/rosbag/__init__.py
b/paimon-python/pypaimon/multimodal/rosbag/__init__.py
new file mode 100644
index 0000000000..5fbd2ad717
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/rosbag/__init__.py
@@ -0,0 +1,31 @@
+# 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.
+
+"""ROSBag ingestion for multimodal tables."""
+
+from pypaimon.multimodal.rosbag.api import (
+ RosbagLoadResult,
+ RosbagStagingConfig,
+ load_from_rosbag,
+)
+from pypaimon.multimodal.rosbag.source import RosbagSource
+
+__all__ = [
+ "RosbagLoadResult",
+ "RosbagSource",
+ "RosbagStagingConfig",
+ "load_from_rosbag",
+]
diff --git a/paimon-python/pypaimon/multimodal/rosbag/api.py
b/paimon-python/pypaimon/multimodal/rosbag/api.py
new file mode 100644
index 0000000000..03e64a8209
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/rosbag/api.py
@@ -0,0 +1,160 @@
+# 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.
+
+"""Public ROSBag ingestion API."""
+
+import os
+import sys
+from dataclasses import dataclass
+from typing import Callable, Mapping, Optional
+
+from pypaimon.common.options import Options
+from pypaimon.multimodal.rosbag.loader import _load_rosbag_manifests
+from pypaimon.multimodal.rosbag.source import _discover_rosbag_sources
+from pypaimon.multimodal.source_utils import (
+ _SourceFileIO,
+ _validated_source_options,
+ _validate_source_kerberos,
+)
+
+
+@dataclass(frozen=True)
+class RosbagStagingConfig:
+ """Local temporary-space controls for ROSBag ingestion.
+
+ In serial mode, ``max_bytes`` covers remote source copies and validated
+ Arrow IPC output. Ray applies it to each worker's remote source copy;
+ Ray object-store capacity is configured separately.
+ """
+
+ directory: Optional[str] = None
+ max_bytes: Optional[int] = None
+ min_free_bytes: int = 1 << 30
+ copy_buffer_bytes: int = 8 << 20
+
+
+@dataclass(frozen=True)
+class RosbagLoadResult:
+ """Counts and committed snapshot for one ROSBag load.
+
+ ``batch_count`` is the number of transform outputs in serial mode and
+ ``None`` in Ray mode, where Ray may reorganize output blocks.
+ """
+
+ source_count: int
+ batch_count: Optional[int]
+ row_count: int
+ snapshot_id: Optional[int]
+
+
+def load_from_rosbag(
+ table,
+ paths,
+ *,
+ transform: Callable,
+ default_typestore=None,
+ typestore_factory=None,
+ source_options: Optional[Mapping[str, object]] = None,
+ staging: Optional[RosbagStagingConfig] = None,
+ allow_storage_fragment: bool = False):
+ """Validate and append ROS1/ROS2 transforms in one commit.
+
+ ``transform(reader, source)`` returns Arrow tables or record batches.
+ Every source and transform result is validated before a Paimon writer is
+ created. The operation is append-only and a commit exception can have an
+ unknown result, so callers must inspect table state before retrying.
+ """
+ if sys.version_info < (3, 10):
+ raise RuntimeError(
+ "load_from_rosbag requires Python 3.10 or newer; the rosbag "
+ "extra is not available on older Python versions.")
+ if not callable(transform):
+ raise ValueError("transform must be callable.")
+ if default_typestore is not None and typestore_factory is not None:
+ raise ValueError(
+ "default_typestore and typestore_factory are mutually exclusive.")
+ if typestore_factory is not None and not callable(typestore_factory):
+ raise ValueError("typestore_factory must be callable.")
+ if staging is None:
+ staging = RosbagStagingConfig()
+ if not isinstance(staging, RosbagStagingConfig):
+ raise ValueError("staging must be a RosbagStagingConfig.")
+ _validate_staging_config(staging)
+
+ path_values = _path_values(paths)
+ options = _validated_source_options(source_options)
+ _validate_source_kerberos(path_values, options, source_name="ROSBag")
+ source_file_io = _SourceFileIO(Options(options))
+ try:
+ manifests = _discover_rosbag_sources(
+ path_values,
+ source_file_io,
+ allow_storage_fragment=allow_storage_fragment,
+ )
+ if not manifests:
+ return RosbagLoadResult(0, 0, 0, None)
+ try:
+ from rosbags.highlevel import AnyReader
+ except ImportError as error:
+ raise ImportError(
+ "load_from_rosbag requires rosbags; install "
+ "'pypaimon[rosbag]'.") from error
+ return _load_rosbag_manifests(
+ table,
+ manifests,
+ transform,
+ source_file_io,
+ AnyReader,
+ default_typestore=default_typestore,
+ typestore_factory=typestore_factory,
+ staging=staging,
+ )
+ finally:
+ source_file_io.close()
+
+
+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 _validate_staging_config(staging):
+ if (
+ staging.max_bytes is not None
+ and (
+ isinstance(staging.max_bytes, bool)
+ or not isinstance(staging.max_bytes, int)
+ or staging.max_bytes < 0)):
+ raise ValueError("staging.max_bytes must be a non-negative integer.")
+ if (
+ isinstance(staging.min_free_bytes, bool)
+ or not isinstance(staging.min_free_bytes, int)
+ or staging.min_free_bytes < 0):
+ raise ValueError(
+ "staging.min_free_bytes must be a non-negative integer.")
+ if (
+ isinstance(staging.copy_buffer_bytes, bool)
+ or not isinstance(staging.copy_buffer_bytes, int)
+ or staging.copy_buffer_bytes <= 0):
+ raise ValueError(
+ "staging.copy_buffer_bytes must be a positive integer.")
diff --git a/paimon-python/pypaimon/multimodal/rosbag/loader.py
b/paimon-python/pypaimon/multimodal/rosbag/loader.py
new file mode 100644
index 0000000000..7f123948a2
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/rosbag/loader.py
@@ -0,0 +1,377 @@
+# 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.
+
+"""Validate ROSBag transforms before creating a Paimon writer."""
+
+import io
+from contextlib import contextmanager
+from pathlib import Path
+import shutil
+from tempfile import TemporaryDirectory
+from urllib.parse import quote, urlparse
+
+import pyarrow as pa
+
+from pypaimon.multimodal.arrow_utils import strict_arrow_table
+from pypaimon.multimodal.rosbag.source import RosbagSource
+from pypaimon.multimodal.rosbag.staging import (
+ _materialized_rosbag,
+ _verify_manifest_members,
+)
+from pypaimon.multimodal.table import _target_schema
+from pypaimon.write.commit_callback import CommitCallback
+
+
+class _SnapshotRecorder(CommitCallback):
+
+ def __init__(self):
+ self.snapshot_id = None
+
+ def call(self, context):
+ self.snapshot_id = context.snapshot.id
+
+
+class _BoundedStagingOutput(io.RawIOBase):
+ """File output that rejects a write before it exceeds its byte limit."""
+
+ def __init__(self, path, max_bytes):
+ super().__init__()
+ self._output = Path(path).open("wb")
+ self._max_bytes = max_bytes
+ self._reserved_bytes = 0
+
+ def set_reserved_bytes(self, byte_count):
+ required_bytes = self._output.tell() + byte_count
+ if required_bytes > self._max_bytes:
+ self._raise_limit(required_bytes)
+ self._reserved_bytes = byte_count
+
+ def writable(self):
+ return True
+
+ def write(self, value):
+ required_bytes = (
+ self._output.tell() + len(value) + self._reserved_bytes)
+ if required_bytes > self._max_bytes:
+ self._raise_limit(required_bytes)
+ return self._output.write(value)
+
+ def _raise_limit(self, required_bytes):
+ raise ValueError(
+ "ROSBag staging exceeds configured limit of %d bytes "
+ "(requires at least %d bytes)."
+ % (self._max_bytes, required_bytes))
+
+ def tell(self):
+ return self._output.tell()
+
+ def flush(self):
+ if not self._output.closed:
+ self._output.flush()
+
+ def close(self):
+ if not self.closed:
+ try:
+ super().close()
+ finally:
+ self._output.close()
+
+
+@contextmanager
+def _staging_sink(path, max_bytes):
+ if max_bytes is None:
+ with pa.OSFile(str(path), "wb") as sink:
+ yield sink, None
+ return
+ with _BoundedStagingOutput(path, max_bytes) as output:
+ with pa.PythonFile(output, mode="w") as sink:
+ yield sink, output
+
+
+def _load_rosbag_manifests(
+ table,
+ manifests,
+ transform,
+ source_file_io,
+ AnyReader,
+ *,
+ default_typestore,
+ typestore_factory,
+ staging):
+ from pypaimon.multimodal.rosbag.api import RosbagLoadResult
+
+ target_schema = _target_schema(table.raw_table)
+ with TemporaryDirectory(
+ prefix="pypaimon_rosbag_", dir=staging.directory) as temp_dir:
+ _check_staging_free_space(temp_dir, staging)
+ spool_path = Path(temp_dir) / "validated.arrow"
+ batch_count = 0
+ row_count = 0
+ with _staging_sink(
+ spool_path, staging.max_bytes) as (sink, bounded_output):
+ with pa.ipc.new_file(sink, target_schema) as spool:
+ for source_index, manifest in enumerate(manifests):
+ source_staging_bytes = _source_staging_bytes(manifest)
+ _check_staging_bytes(
+ sink.tell() + source_staging_bytes, staging)
+ if bounded_output is not None:
+ bounded_output.set_reserved_bytes(
+ source_staging_bytes)
+ try:
+ for table_value in _transform_rosbag_manifest(
+ manifest,
+ transform,
+ source_file_io,
+ AnyReader,
+ target_schema,
+ default_typestore=default_typestore,
+ typestore_factory=typestore_factory,
+ staging=staging,
+ staging_root=Path(temp_dir) /
+ ("source-%06d" % source_index),
+ batch_index=batch_count,
+ base_staging_bytes=sink.tell()):
+ batch_count += 1
+ row_count += table_value.num_rows
+ for record_batch in table_value.to_batches():
+ spool.write_batch(record_batch)
+ _check_staging_bytes(
+ sink.tell() + source_staging_bytes,
+ staging)
+ finally:
+ if bounded_output is not None:
+ bounded_output.set_reserved_bytes(0)
+ _check_staging_bytes(sink.tell(), staging)
+
+ for manifest in manifests:
+ _verify_manifest_members(manifest, source_file_io)
+ snapshot_id = _write_spool(table, spool_path)
+ return RosbagLoadResult(
+ source_count=len(manifests),
+ batch_count=batch_count,
+ row_count=row_count,
+ snapshot_id=snapshot_id,
+ )
+
+
+def _source_staging_bytes(manifest):
+ if urlparse(manifest.uri).scheme.lower() == "file":
+ return 0
+ return sum(member.size for member in manifest.members)
+
+
+def _check_staging_bytes(actual_bytes, staging):
+ if staging.max_bytes is not None and actual_bytes > staging.max_bytes:
+ raise ValueError(
+ "ROSBag staging exceeds configured limit of %d bytes "
+ "(requires at least %d bytes)."
+ % (staging.max_bytes, actual_bytes))
+
+
+def _check_staging_free_space(directory, staging):
+ if shutil.disk_usage(str(directory)).free < staging.min_free_bytes:
+ raise ValueError(
+ "ROSBag staging has less than %d free bytes at %s."
+ % (staging.min_free_bytes, directory))
+
+
+def _transform_rosbag_manifest(
+ manifest,
+ transform,
+ source_file_io,
+ AnyReader,
+ target_schema,
+ *,
+ default_typestore,
+ typestore_factory,
+ staging,
+ staging_root,
+ batch_index=0,
+ base_staging_bytes=0):
+ """Yield validated Arrow tables for one fully preflighted source."""
+ produced_rows = 0
+ with _materialized_rosbag(
+ manifest,
+ source_file_io,
+ staging_root,
+ staging,
+ base_staging_bytes=base_staging_bytes) as local_path:
+ if manifest.format == "ros2_sqlite3_fragment":
+ _validate_sqlite_fragment(local_path, manifest.uri)
+ _preflight_reader(
+ manifest,
+ local_path,
+ AnyReader,
+ default_typestore,
+ typestore_factory,
+ )
+ source = RosbagSource(
+ uri=manifest.uri,
+ local_path=local_path,
+ format=manifest.format,
+ )
+ typestore = _new_typestore(default_typestore, typestore_factory)
+ with AnyReader(
+ [local_path], default_typestore=typestore) as reader:
+ transformed = transform(reader, source)
+ batches = None
+ try:
+ batches = _arrow_batches(transformed)
+ for index, value in enumerate(batches, start=batch_index):
+ table_value = strict_arrow_table(
+ value,
+ target_schema,
+ manifest.uri,
+ index,
+ "ROSBag",
+ )
+ produced_rows += table_value.num_rows
+ yield table_value
+ finally:
+ _close_transform_iterator(
+ batches if batches is not None else transformed)
+ if produced_rows == 0:
+ raise ValueError(
+ "ROSBag source %s produced no rows." % manifest.uri)
+
+
+def _validate_sqlite_fragment(local_path, source_uri):
+ import sqlite3
+
+ database_uri = "file:%s?mode=ro&immutable=1" % quote(
+ str(local_path), safe="/")
+ try:
+ connection = sqlite3.connect(database_uri, uri=True)
+ try:
+ results = [row[0] for row in connection.execute(
+ "PRAGMA quick_check")]
+ finally:
+ connection.close()
+ except sqlite3.DatabaseError as error:
+ raise ValueError(
+ "SQLite integrity check failed for ROSBag fragment %s: %s"
+ % (source_uri, error)) from error
+ if results != ["ok"]:
+ raise ValueError(
+ "SQLite integrity check failed for ROSBag fragment %s: %s"
+ % (source_uri, "; ".join(results)))
+
+
+def _preflight_reader(
+ manifest,
+ local_path,
+ AnyReader,
+ default_typestore,
+ typestore_factory):
+ typestore = _new_typestore(default_typestore, typestore_factory)
+ with AnyReader([local_path], default_typestore=typestore) as reader:
+ topic_counts = {}
+ actual_count = 0
+ for connection, _, _ in reader.messages():
+ actual_count += 1
+ topic_counts[connection.id] = (
+ topic_counts.get(connection.id, 0) + 1)
+ declared_count = reader.message_count
+ connections = list(reader.connections)
+ if actual_count != declared_count:
+ raise ValueError(
+ "ROSBag source %s declared %d messages but %d were readable."
+ % (manifest.uri, declared_count, actual_count))
+ if (
+ manifest.expected_message_count is not None
+ and actual_count != manifest.expected_message_count):
+ raise ValueError(
+ "ROSBag source %s metadata declares %d messages but %d were "
+ "readable."
+ % (
+ manifest.uri,
+ manifest.expected_message_count,
+ actual_count,
+ ))
+ for connection in connections:
+ actual_topic_count = topic_counts.get(connection.id, 0)
+ if actual_topic_count != connection.msgcount:
+ raise ValueError(
+ "ROSBag source %s topic %s declares %d messages but %d "
+ "were readable."
+ % (
+ manifest.uri,
+ connection.topic,
+ connection.msgcount,
+ actual_topic_count,
+ ))
+
+
+def _new_typestore(default_typestore, typestore_factory):
+ if typestore_factory is not None:
+ return typestore_factory()
+ return default_typestore
+
+
+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(
+ "ROSBag transform must return Arrow data or an iterable of "
+ "Arrow data.")
+ try:
+ return iter(transformed)
+ except TypeError as error:
+ raise ValueError(
+ "ROSBag 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()
+
+
+def _write_spool(table, spool_path):
+ write_builder = table.raw_table.new_batch_write_builder()
+ table_write = None
+ table_commit = None
+ commit_started = False
+ snapshot_recorder = _SnapshotRecorder()
+ try:
+ table_write = write_builder.new_write()
+ table_commit = write_builder.new_commit()
+ table_commit.add_commit_callback(snapshot_recorder)
+ with pa.memory_map(str(spool_path), "r") as source:
+ spool = pa.ipc.open_file(source)
+ for index in range(spool.num_record_batches):
+ table_write.write_arrow(pa.Table.from_batches([
+ spool.get_batch(index)]))
+ commit_messages = table_write.prepare_commit()
+ commit_started = True
+ table_commit.commit(commit_messages)
+ if snapshot_recorder.snapshot_id is None:
+ raise RuntimeError(
+ "ROSBag append committed without reporting a snapshot id.")
+ return 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()
diff --git a/paimon-python/pypaimon/multimodal/rosbag/source.py
b/paimon-python/pypaimon/multimodal/rosbag/source.py
new file mode 100644
index 0000000000..6d43c6d775
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/rosbag/source.py
@@ -0,0 +1,371 @@
+# 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.
+
+"""ROSBag source descriptions and discovery."""
+
+from contextlib import closing
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import Optional, Tuple
+from urllib.parse import quote, unquote, urlparse
+
+import pyarrow.fs as pafs
+
+from pypaimon.multimodal.source_utils import (
+ _normalize_source_path,
+ _qualified_status_path,
+)
+
+
+@dataclass(frozen=True)
+class RosbagSourceMember:
+ """One physical file belonging to a logical ROSBag source."""
+
+ uri: str
+ relative_path: str
+ size: int
+ mtime_ns: Optional[int]
+
+
+@dataclass(frozen=True)
+class RosbagSourceManifest:
+ """Immutable physical-file manifest for one logical ROSBag source."""
+
+ uri: str
+ format: str
+ members: Tuple[RosbagSourceMember, ...]
+ expected_message_count: Optional[int] = None
+ directory_members: Optional[Tuple[str, ...]] = None
+
+
+@dataclass(frozen=True)
+class RosbagSource:
+ """Read context supplied to a ROSBag transform.
+
+ ``format`` is one of ``ros1``, ``ros2_mcap``, ``ros2_sqlite3``, or
+ ``ros2_sqlite3_fragment``.
+ """
+
+ uri: str
+ local_path: Path
+ format: str
+
+ @property
+ def name(self):
+ """Base name of the original source URI."""
+ return PurePosixPath(unquote(urlparse(self.uri).path)).name
+
+ @property
+ def stem(self):
+ """Base name without the final suffix."""
+ return PurePosixPath(self.name).stem
+
+ @property
+ def is_remote(self):
+ """Whether the original source requires FileIO materialization."""
+ return urlparse(self.uri).scheme.lower() != "file"
+
+
+def _discover_rosbag_sources(
+ paths, source_file_io, allow_storage_fragment=False):
+ """Discover normalized logical ROSBag sources."""
+ manifests = {}
+ visited_directories = set()
+ for value in paths:
+ path = _normalize_source_path(value)
+ try:
+ status = source_file_io.get_file_status(path)
+ except FileNotFoundError as not_found_error:
+ try:
+ children = source_file_io.list_status(path)
+ except (FileNotFoundError, KeyError):
+ raise ValueError(
+ "ROSBag source path does not exist: %s" % path
+ ) from not_found_error
+ if not children:
+ raise ValueError(
+ "ROSBag source path does not exist: %s" % path
+ ) from not_found_error
+ status = pafs.FileInfo(path, pafs.FileType.Directory)
+ _discover_status(
+ path,
+ status,
+ source_file_io,
+ manifests,
+ visited_directories,
+ allow_storage_fragment,
+ explicit=True,
+ )
+ return [manifests[key] for key in sorted(manifests)]
+
+
+def _discover_status(
+ parent_path,
+ status,
+ source_file_io,
+ manifests,
+ visited_directories,
+ allow_storage_fragment,
+ *,
+ explicit):
+ qualified = _qualified_status_path(parent_path, status)
+ if status.type == pafs.FileType.File:
+ manifest = _file_manifest(
+ qualified,
+ status,
+ allow_storage_fragment,
+ explicit=explicit,
+ )
+ if manifest is not None:
+ manifests[manifest.uri] = manifest
+ return
+ if status.type != pafs.FileType.Directory:
+ if explicit:
+ raise ValueError("Unsupported ROSBag source: %s" % qualified)
+ return
+ if qualified in visited_directories:
+ return
+ visited_directories.add(qualified)
+
+ metadata_uri = _join_source_path(qualified, "metadata.yaml")
+ try:
+ source_file_io.get_file_status(metadata_uri)
+ except FileNotFoundError:
+ children = source_file_io.list_status(qualified)
+ if explicit and any(
+ _storage_member_status(qualified, child)
+ for child in children):
+ raise ValueError(
+ "ROS2 recording directory is missing metadata.yaml: %s"
+ % qualified)
+ for child in children:
+ _discover_status(
+ qualified,
+ child,
+ source_file_io,
+ manifests,
+ visited_directories,
+ allow_storage_fragment,
+ explicit=False,
+ )
+ return
+
+ manifest = _ros2_directory_manifest(qualified, source_file_io)
+ manifests[manifest.uri] = manifest
+
+
+def _storage_member_status(parent, status):
+ if status.type != pafs.FileType.File:
+ return False
+ path = _qualified_status_path(parent, status)
+ suffix = PurePosixPath(unquote(urlparse(path).path)).suffix.lower()
+ return suffix in (".db3", ".mcap")
+
+
+def _file_manifest(path, status, allow_storage_fragment, *, explicit):
+ decoded_path = unquote(urlparse(path).path)
+ if decoded_path.lower().endswith(".bag.active"):
+ raise ValueError(
+ "ROS1 recording appears to be still being written: %s" % path)
+ suffix = PurePosixPath(decoded_path).suffix.lower()
+ formats = {".bag": "ros1", ".mcap": "ros2_mcap"}
+ if suffix == ".db3" and not allow_storage_fragment:
+ raise ValueError(
+ "Standalone ROS2 .db3 files may be incomplete recording "
+ "fragments; pass allow_storage_fragment=True to import the "
+ "file without recording completeness guarantees: %s" % path)
+ if suffix == ".db3" and explicit:
+ formats[suffix] = "ros2_sqlite3_fragment"
+ if suffix not in formats:
+ if explicit:
+ raise ValueError("Unsupported ROSBag source file: %s" % path)
+ return None
+ member = RosbagSourceMember(
+ uri=path,
+ relative_path=PurePosixPath(decoded_path).name,
+ size=status.size,
+ mtime_ns=_status_mtime_ns(status),
+ )
+ return RosbagSourceManifest(
+ uri=path,
+ format=formats[suffix],
+ members=(member,),
+ )
+
+
+def _ros2_directory_manifest(directory, source_file_io):
+ metadata_uri = _join_source_path(directory, "metadata.yaml")
+ try:
+ metadata_status = source_file_io.get_file_status(metadata_uri)
+ except FileNotFoundError as error:
+ raise ValueError(
+ "ROS2 recording is missing metadata.yaml: %s" % directory
+ ) from error
+ if metadata_status.type != pafs.FileType.File:
+ raise ValueError(
+ "ROS2 metadata.yaml is not a file: %s" % metadata_uri)
+
+ metadata = _read_ros2_metadata(metadata_uri, source_file_io)
+ storage_identifier = metadata.get("storage_identifier")
+ formats = {
+ "sqlite3": "ros2_sqlite3",
+ "mcap": "ros2_mcap",
+ }
+ if storage_identifier not in formats:
+ raise ValueError(
+ "Unsupported ROS2 storage identifier %r in %s."
+ % (storage_identifier, metadata_uri))
+
+ members = [
+ _source_member(
+ "metadata.yaml",
+ metadata_status,
+ directory,
+ )
+ ]
+ normalized_members = set()
+ for value in metadata.get("relative_file_paths", []):
+ relative_path = _safe_ros2_member_path(value)
+ collision_key = relative_path.casefold()
+ if collision_key in normalized_members:
+ raise ValueError(
+ "duplicate ROS2 metadata relative_file_paths entry: %r"
+ % value)
+ normalized_members.add(collision_key)
+ member_uri = _join_source_path(directory, relative_path)
+ if storage_identifier == "sqlite3":
+ _reject_sqlite_sidecars(member_uri, source_file_io)
+ try:
+ status = source_file_io.get_file_status(member_uri)
+ except FileNotFoundError as error:
+ raise ValueError(
+ "ROS2 recording member is missing: %s" % member_uri
+ ) from error
+ if status.type != pafs.FileType.File:
+ raise ValueError(
+ "ROS2 recording member is not a file: %s" % member_uri)
+ members.append(_source_member(
+ relative_path,
+ status,
+ directory,
+ ))
+
+ return RosbagSourceManifest(
+ uri=directory,
+ format=formats[storage_identifier],
+ members=tuple(members),
+ expected_message_count=int(metadata.get("message_count", 0)),
+ directory_members=_listed_directory_members(
+ directory, source_file_io),
+ )
+
+
+def _read_ros2_metadata(metadata_uri, source_file_io):
+ try:
+ from ruamel.yaml import YAML
+ from ruamel.yaml.error import YAMLError
+ except ImportError as error:
+ raise ImportError(
+ "ROSBag loading requires rosbags; install 'pypaimon[rosbag]'."
+ ) from error
+ with closing(source_file_io.new_input_stream(metadata_uri)) as stream:
+ content = stream.read()
+ try:
+ document = YAML(typ="safe").load(content)
+ return document["rosbag2_bagfile_information"]
+ except (KeyError, TypeError, ValueError, YAMLError) as error:
+ raise ValueError(
+ "Cannot read ROS2 metadata: %s" % metadata_uri) from error
+
+
+def _listed_directory_members(directory, source_file_io):
+ return tuple(sorted(
+ _qualified_status_path(directory, status)
+ for status in source_file_io.list_status(directory)
+ ))
+
+
+def _source_member(relative_path, status, parent):
+ return RosbagSourceMember(
+ uri=_qualified_status_path(parent, status),
+ relative_path=relative_path,
+ size=status.size,
+ mtime_ns=_status_mtime_ns(status),
+ )
+
+
+def _join_source_path(directory, relative_path):
+ return "%s/%s" % (
+ directory.rstrip("/"), quote(relative_path, safe="/:%"))
+
+
+def _reject_sqlite_sidecars(database_uri, source_file_io):
+ for suffix in ("-wal", "-shm", "-journal"):
+ sidecar = database_uri + suffix
+ try:
+ source_file_io.get_file_status(sidecar)
+ except FileNotFoundError:
+ continue
+ raise ValueError(
+ "ROS2 SQLite recording is not finalized or requires recovery; "
+ "found sidecar file: %s" % sidecar)
+
+
+def _safe_ros2_member_path(value):
+ if not isinstance(value, str) or not value:
+ raise ValueError(
+ "unsafe ROS2 metadata relative_file_paths entry: %r"
+ % value)
+ decoded = value
+ for _ in range(8):
+ next_value = unquote(decoded)
+ if next_value == decoded:
+ break
+ decoded = next_value
+ else:
+ raise ValueError(
+ "unsafe ROS2 metadata relative_file_paths entry: %r"
+ % value)
+ parsed = urlparse(decoded)
+ path = PurePosixPath(decoded)
+ if (
+ "\\" in decoded
+ or parsed.scheme
+ or parsed.netloc
+ or parsed.query
+ or parsed.fragment
+ or path.is_absolute()
+ or any(part in ("", ".", "..") for part in path.parts)):
+ raise ValueError(
+ "unsafe ROS2 metadata relative_file_paths entry: %r"
+ % value)
+ if len(path.parts) != 1:
+ raise ValueError(
+ "nested ROS2 metadata relative_file_paths entries are not "
+ "supported by rosbags: %r" % value)
+ return path.as_posix()
+
+
+def _status_mtime_ns(status):
+ value = getattr(status, "mtime_ns", None)
+ if value is not None:
+ return None if value < 0 else value
+ value = getattr(status, "mtime", None)
+ if value is None:
+ return None
+ if hasattr(value, "timestamp"):
+ value = value.timestamp()
+ return None if value < 0 else int(value * 1_000_000_000)
diff --git a/paimon-python/pypaimon/multimodal/rosbag/staging.py
b/paimon-python/pypaimon/multimodal/rosbag/staging.py
new file mode 100644
index 0000000000..77daf0b909
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/rosbag/staging.py
@@ -0,0 +1,158 @@
+# 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.
+
+"""Bounded local materialization for ROSBag FileIO sources."""
+
+import shutil
+from contextlib import closing, contextmanager
+from pathlib import Path
+from urllib.parse import urlparse
+
+import pyarrow.fs as pafs
+
+from pypaimon.multimodal.rosbag.source import _status_mtime_ns
+from pypaimon.multimodal.source_utils import _qualified_status_path
+
+
+@contextmanager
+def _materialized_rosbag(
+ manifest,
+ source_file_io,
+ staging_root,
+ staging,
+ base_staging_bytes=0):
+ """Yield a local file/directory and reject detected source mutations."""
+ if urlparse(manifest.uri).scheme.lower() == "file":
+ local_path = Path(source_file_io.to_filesystem_path(manifest.uri))
+ yield local_path
+ _verify_manifest_members(manifest, source_file_io)
+ return
+
+ if staging.copy_buffer_bytes <= 0:
+ raise ValueError("staging.copy_buffer_bytes must be positive.")
+ staging_root.mkdir(parents=True, exist_ok=False)
+ try:
+ local_path = _stage_remote_manifest(
+ manifest,
+ source_file_io,
+ staging_root,
+ staging,
+ base_staging_bytes=base_staging_bytes,
+ )
+ yield local_path
+ _verify_manifest_members(manifest, source_file_io)
+ finally:
+ shutil.rmtree(str(staging_root), ignore_errors=True)
+
+
+def _stage_remote_manifest(
+ manifest,
+ source_file_io,
+ staging_root,
+ staging,
+ base_staging_bytes=0):
+ if shutil.disk_usage(str(staging_root)).free < staging.min_free_bytes:
+ raise ValueError(
+ "ROSBag staging has less than %d free bytes at %s."
+ % (staging.min_free_bytes, staging_root))
+
+ expected_bytes = sum(member.size for member in manifest.members)
+ if (
+ staging.max_bytes is not None
+ and base_staging_bytes + expected_bytes > staging.max_bytes):
+ raise ValueError(
+ "ROSBag staging exceeds configured limit of %d bytes while "
+ "copying %s."
+ % (staging.max_bytes, manifest.uri))
+
+ total_copied = 0
+ for member in manifest.members:
+ destination = staging_root / member.relative_path
+ _require_contained_path(staging_root, destination)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ copied = 0
+ with closing(source_file_io.new_input_stream(member.uri)) as source:
+ with destination.open("wb") as output:
+ while True:
+ chunk = source.read(staging.copy_buffer_bytes)
+ if not chunk:
+ break
+ next_total = total_copied + len(chunk)
+ if (
+ staging.max_bytes is not None
+ and base_staging_bytes + next_total
+ > staging.max_bytes):
+ raise ValueError(
+ "ROSBag staging exceeds configured limit of %d "
+ "bytes while copying %s."
+ % (staging.max_bytes, member.uri))
+ output.write(chunk)
+ copied += len(chunk)
+ total_copied = next_total
+ if copied != member.size:
+ raise ValueError(
+ "ROSBag source %s changed or returned a short read: "
+ "expected %d bytes, copied %d."
+ % (member.uri, member.size, copied))
+
+ _verify_manifest_members(manifest, source_file_io)
+ local_path = (
+ staging_root
+ if any(member.relative_path == "metadata.yaml"
+ for member in manifest.members)
+ else staging_root / manifest.members[0].relative_path
+ )
+ return local_path
+
+
+def _verify_manifest_members(manifest, source_file_io):
+ if manifest.directory_members is not None:
+ actual_members = tuple(sorted(
+ _qualified_status_path(manifest.uri, status)
+ for status in source_file_io.list_status(manifest.uri)
+ ))
+ if actual_members != manifest.directory_members:
+ raise ValueError(
+ "ROSBag source directory members changed during ingestion: "
+ "%s" % manifest.uri)
+ for member in manifest.members:
+ try:
+ status = source_file_io.get_file_status(member.uri)
+ except FileNotFoundError as error:
+ raise ValueError(
+ "ROSBag source changed during ingestion; member disappeared: "
+ "%s" % member.uri) from error
+ if status.type != pafs.FileType.File or status.size != member.size:
+ raise ValueError(
+ "ROSBag source changed during ingestion: %s" % member.uri)
+ mtime_ns = _status_mtime_ns(status)
+ if (
+ member.mtime_ns is not None
+ and mtime_ns is not None
+ and mtime_ns != member.mtime_ns):
+ raise ValueError(
+ "ROSBag source changed during ingestion: %s" % member.uri)
+
+
+def _require_contained_path(root, destination):
+ resolved_root = root.resolve()
+ resolved_destination = destination.resolve()
+ try:
+ resolved_destination.relative_to(resolved_root)
+ except ValueError as error:
+ raise ValueError(
+ "ROSBag staging member escapes its temporary directory: %s"
+ % destination) from error
diff --git a/paimon-python/pypaimon/multimodal/source_utils.py
b/paimon-python/pypaimon/multimodal/source_utils.py
index 1e3c58014e..6eb0e27823 100644
--- a/paimon-python/pypaimon/multimodal/source_utils.py
+++ b/paimon-python/pypaimon/multimodal/source_utils.py
@@ -17,8 +17,44 @@
"""Shared validation for external multimodal sources."""
import os
+import re
+from pathlib import Path, PureWindowsPath
from typing import Mapping
-from urllib.parse import urlparse
+from urllib.parse import quote, unquote, urlparse, urlunparse
+
+from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+
+
+class _SourceFileIO:
+ """Resolve external source URIs without using target warehouse options."""
+
+ 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 to_filesystem_path(self, path):
+ return self._resolve(path)[1]
+
+ def close(self):
+ self._resolver.close()
def _source_path_text(value):
@@ -32,6 +68,61 @@ def _source_path_text(value):
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 _validated_source_options(source_options):
if source_options is None:
return {}
diff --git a/paimon-python/pypaimon/ray/__init__.py
b/paimon-python/pypaimon/ray/__init__.py
index e29d845061..fc0a8818fd 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -32,6 +32,7 @@ from pypaimon.ray.update_by_row_id import update_by_row_id
from pypaimon.ray.read_by_row_id import read_by_row_id
from pypaimon.ray.process_row_id_ranges import process_row_id_ranges
from pypaimon.ray.hdf5 import load_from_hdf5
+from pypaimon.ray.rosbag import load_from_rosbag
__all__ = [
"read_paimon",
@@ -44,6 +45,7 @@ __all__ = [
"read_by_row_id",
"process_row_id_ranges",
"load_from_hdf5",
+ "load_from_rosbag",
"WhenMatched",
"WhenNotMatched",
"source_col",
diff --git a/paimon-python/pypaimon/ray/rosbag.py
b/paimon-python/pypaimon/ray/rosbag.py
new file mode 100644
index 0000000000..8347dda7aa
--- /dev/null
+++ b/paimon-python/pypaimon/ray/rosbag.py
@@ -0,0 +1,212 @@
+# 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.
+
+"""Distributed ROSBag validation and ingestion with a materialize barrier."""
+
+import pickle
+from pathlib import Path
+import sys
+from tempfile import TemporaryDirectory
+from typing import Any, Dict, Mapping, Optional
+
+
+def load_from_rosbag(
+ table_identifier: str,
+ paths,
+ catalog_options: Dict[str, str],
+ *,
+ transform,
+ default_typestore=None,
+ typestore_factory=None,
+ source_options: Optional[Mapping[str, object]] = None,
+ staging=None,
+ allow_storage_fragment: bool = False,
+ concurrency: Optional[int] = None,
+ ray_remote_args: Optional[Dict[str, Any]] = None):
+ """Validate ROSBag sources on Ray before starting the Paimon sink.
+
+ ``transform(reader, source)`` returns Arrow tables or record batches.
+ Ray may retry it, so it must be deterministic and free of non-idempotent
+ external side effects. Validated output is materialized before the
+ append-only Paimon write starts; a commit exception may have an unknown
+ result.
+ """
+ if sys.version_info < (3, 10):
+ raise RuntimeError(
+ "Ray ROSBag loading requires Python 3.10 or newer; the rosbag "
+ "extra is not available on older Python versions.")
+ if not callable(transform):
+ raise ValueError("transform must be callable.")
+ if default_typestore is not None and typestore_factory is not None:
+ raise ValueError(
+ "default_typestore and typestore_factory are mutually exclusive.")
+ if typestore_factory is not None and not callable(typestore_factory):
+ raise ValueError("typestore_factory must be callable.")
+
+ from pypaimon.catalog.catalog_factory import CatalogFactory
+ from pypaimon.common.options import Options
+ from pypaimon.multimodal.rosbag.api import (
+ RosbagLoadResult,
+ RosbagStagingConfig,
+ _path_values,
+ _validate_staging_config,
+ )
+ from pypaimon.multimodal.rosbag.source import _discover_rosbag_sources
+ from pypaimon.multimodal.source_utils import (
+ _SourceFileIO,
+ _validated_source_options,
+ _validate_source_kerberos,
+ )
+ from pypaimon.multimodal.table import _target_schema
+ from pypaimon.ray.ray_paimon import _require_ray_data, write_paimon
+
+ if staging is None:
+ staging = RosbagStagingConfig()
+ if not isinstance(staging, RosbagStagingConfig):
+ raise ValueError("staging must be a RosbagStagingConfig.")
+ _validate_staging_config(staging)
+ options = _validated_source_options(source_options)
+ path_values = _path_values(paths)
+ _validate_source_kerberos(path_values, options, source_name="ROSBag")
+ source_file_io = _SourceFileIO(Options(options))
+ try:
+ manifests = _discover_rosbag_sources(
+ path_values,
+ source_file_io,
+ allow_storage_fragment=allow_storage_fragment,
+ )
+ finally:
+ source_file_io.close()
+ if not manifests:
+ return RosbagLoadResult(0, None, 0, None)
+
+ ray_data = _require_ray_data()
+ _require_ray_serializable("transform", transform)
+ if default_typestore is not None:
+ _require_ray_serializable("default_typestore", default_typestore)
+ if typestore_factory is not None:
+ _require_ray_serializable("typestore_factory", typestore_factory)
+ table = CatalogFactory.create(catalog_options).get_table(table_identifier)
+ target_schema = _target_schema(table)
+ inputs = ray_data.from_items(
+ [{"manifest": pickle.dumps(manifest)} for manifest in manifests],
+ override_num_blocks=len(manifests),
+ )
+ transformed = inputs.map_batches(
+ _TransformRosbagSource,
+ fn_constructor_kwargs={
+ "transform": transform,
+ "default_typestore": default_typestore,
+ "typestore_factory": typestore_factory,
+ "source_options": options,
+ "staging": staging,
+ "target_schema": target_schema,
+ },
+ batch_format="pyarrow",
+ batch_size=1,
+ concurrency=concurrency,
+ **dict(ray_remote_args or {}),
+ )
+ validated = transformed.materialize()
+ from pypaimon.multimodal.rosbag.staging import _verify_manifest_members
+ source_file_io = _SourceFileIO(Options(options))
+ try:
+ for manifest in manifests:
+ _verify_manifest_members(manifest, source_file_io)
+ finally:
+ source_file_io.close()
+ write_result = write_paimon(
+ validated,
+ table_identifier,
+ catalog_options,
+ concurrency=concurrency,
+ ray_remote_args=ray_remote_args,
+ )
+ return RosbagLoadResult(
+ source_count=len(manifests),
+ batch_count=None,
+ row_count=0 if write_result is None else write_result.row_count,
+ snapshot_id=(
+ None if write_result is None else write_result.snapshot_id),
+ )
+
+
+def _require_ray_serializable(name, value):
+ import ray.cloudpickle as cloudpickle
+
+ try:
+ cloudpickle.dumps(value)
+ except Exception as error:
+ raise ValueError(
+ "%s must be Ray-serializable: %s" % (name, error)) from error
+
+
+class _TransformRosbagSource:
+ """Ray callable that will validate one complete source per input batch."""
+
+ def __init__(
+ self,
+ *,
+ transform,
+ default_typestore,
+ typestore_factory,
+ source_options,
+ staging,
+ target_schema):
+ self.transform = transform
+ self.default_typestore = default_typestore
+ self.typestore_factory = typestore_factory
+ self.source_options = source_options
+ self.staging = staging
+ self.target_schema = target_schema
+
+ def __call__(self, batch):
+ if batch.num_rows != 1:
+ raise ValueError(
+ "Ray ROSBag transform requires one source per batch.")
+
+ from pypaimon.common.options import Options
+ from pypaimon.multimodal.rosbag.loader import (
+ _transform_rosbag_manifest,
+ )
+ from pypaimon.multimodal.source_utils import _SourceFileIO
+ try:
+ from rosbags.highlevel import AnyReader
+ except ImportError as error:
+ raise ImportError(
+ "Ray ROSBag loading requires rosbags on every worker; "
+ "install 'pypaimon[ray,rosbag]'.") from error
+
+ manifest = pickle.loads(batch["manifest"][0].as_py())
+ source_file_io = _SourceFileIO(Options(self.source_options))
+ try:
+ with TemporaryDirectory(
+ prefix="pypaimon_ray_rosbag_",
+ dir=self.staging.directory) as temp_dir:
+ for table in _transform_rosbag_manifest(
+ manifest,
+ self.transform,
+ source_file_io,
+ AnyReader,
+ self.target_schema,
+ default_typestore=self.default_typestore,
+ typestore_factory=self.typestore_factory,
+ staging=self.staging,
+ staging_root=Path(temp_dir) / "source"):
+ if table.num_rows:
+ yield table
+ finally:
+ source_file_io.close()
diff --git a/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
b/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
index 27063cefc2..7131060dbc 100644
--- a/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_hdf5_test.py
@@ -71,7 +71,7 @@ class Hdf5RuntimeContractTest(unittest.TestCase):
resolver._get_fileio.return_value = backend
with patch(
- "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
return_value=resolver):
source_io = _Hdf5SourceFileIO(Options({}))
self.assertEqual(
@@ -501,7 +501,7 @@ class MultimodalHdf5Test(unittest.TestCase):
yield from self._transform(h5, source)
with patch(
- "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
return_value=source_file_io) as resolving_file_io:
result = self._load(
table,
@@ -541,7 +541,7 @@ class MultimodalHdf5Test(unittest.TestCase):
proxy, writer, committer = self._instrument_write(table)
with patch(
- "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
return_value=source_file_io), patch.object(
table.raw_table,
"new_batch_write_builder",
@@ -566,7 +566,7 @@ class MultimodalHdf5Test(unittest.TestCase):
new_builder = Mock()
with patch(
- "pypaimon.multimodal.hdf5.ResolvingFileIO",
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
return_value=source_file_io), patch.object(
table.raw_table,
"new_batch_write_builder",
@@ -790,7 +790,7 @@ class MultimodalHdf5Test(unittest.TestCase):
with patch.object(
self.conn, "get_table", return_value=table), patch(
- "pypaimon.multimodal.hdf5.ResolvingFileIO") as resolving:
+ "pypaimon.multimodal.source_utils.ResolvingFileIO") as
resolving:
with self.assertRaisesRegex(
ValueError, "process-isolated"):
self.conn.load_from_hdf5(
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 7f1f8d10a8..7362c91d23 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -29,7 +29,7 @@ import pyarrow.fs as pafs
import pypaimon.multimodal as pmm
from pypaimon.common.options import Options
-from pypaimon.multimodal.hdf5 import _Hdf5SourceFileIO
+from pypaimon.multimodal.source_utils import _SourceFileIO
from pypaimon.multimodal.lerobot import load_from_lerobot
from pypaimon.multimodal.lerobot.loader import (
_image_bytes,
@@ -136,7 +136,7 @@ class LeRobotValidationTest(unittest.TestCase):
def test_double_encoded_file_uri_cannot_escape_source(self):
temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_uri_"))
- source_file_io = _Hdf5SourceFileIO(Options({}))
+ source_file_io = _SourceFileIO(Options({}))
try:
root = temp_dir / "source"
root.mkdir()
@@ -163,7 +163,7 @@ class LeRobotValidationTest(unittest.TestCase):
def test_hdfs_source_rejects_explicit_keytab_before_resolution(self):
with patch(
- "pypaimon.multimodal.lerobot.source._Hdf5SourceFileIO"
+ "pypaimon.multimodal.lerobot.source._SourceFileIO"
) as source_file_io:
with self.assertRaisesRegex(ValueError, "process-isolated"):
load_from_lerobot(
@@ -685,7 +685,7 @@ class LeRobotImportTest(unittest.TestCase):
source_file_io = _RemoteLeRobotFileIO(self.image_source, source)
with patch(
- "pypaimon.multimodal.lerobot.source._Hdf5SourceFileIO",
+ "pypaimon.multimodal.lerobot.source._SourceFileIO",
return_value=source_file_io):
snapshot_id = self.connection.load_from_lerobot(
"oss_images",
diff --git a/paimon-python/pypaimon/tests/multimodal_rosbag_test.py
b/paimon-python/pypaimon/tests/multimodal_rosbag_test.py
new file mode 100644
index 0000000000..b56056ffda
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_rosbag_test.py
@@ -0,0 +1,889 @@
+# 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
+from pathlib import Path
+import sys
+from unittest.mock import Mock, patch
+from urllib.parse import urlparse
+
+import pytest
+import pyarrow as pa
+import pyarrow.fs as pafs
+
+import pypaimon.multimodal as pmm
+from pypaimon.common.options import Options
+from pypaimon.multimodal.rosbag import RosbagSource
+from pypaimon.multimodal.rosbag.api import (
+ RosbagStagingConfig,
+ load_from_rosbag,
+)
+from pypaimon.multimodal.rosbag.loader import _BoundedStagingOutput
+from pypaimon.multimodal.rosbag.source import _discover_rosbag_sources
+from pypaimon.multimodal.rosbag.staging import (
+ _materialized_rosbag,
+ _stage_remote_manifest,
+ _verify_manifest_members,
+)
+from pypaimon.multimodal.source_utils import _SourceFileIO
+
+
+pytestmark = pytest.mark.skipif(
+ sys.version_info < (3, 10),
+ reason="rosbags 0.11 requires Python 3.10 or newer",
+)
+
+
+_MSGTYPE = "std_msgs/msg/String"
+_START_NS = 1_700_000_000_000_000_000
+
+
+def _write_ros2(path, storage_plugin, values=("first", "second")):
+ from rosbags.rosbag2 import Writer
+ from rosbags.typesys import Stores, get_typestore
+
+ typestore = get_typestore(Stores.ROS2_HUMBLE)
+ with Writer(
+ path,
+ version=Writer.VERSION_LATEST,
+ storage_plugin=storage_plugin) as writer:
+ connection = writer.add_connection(
+ "/text", _MSGTYPE, typestore=typestore)
+ for index, value in enumerate(values):
+ message = typestore.types[_MSGTYPE](data=value)
+ writer.write(
+ connection,
+ _START_NS + index,
+ typestore.serialize_cdr(message, _MSGTYPE),
+ )
+ return path
+
+
+def _write_ros1(path, values=("first", "second")):
+ from rosbags.rosbag1 import Writer
+ from rosbags.typesys import Stores, get_typestore
+
+ typestore = get_typestore(Stores.ROS1_NOETIC)
+ with Writer(path) as writer:
+ connection = writer.add_connection(
+ "/text", _MSGTYPE, typestore=typestore)
+ for index, value in enumerate(values):
+ message = typestore.types[_MSGTYPE](data=value)
+ writer.write(
+ connection,
+ _START_NS + index,
+ typestore.serialize_ros1(message, _MSGTYPE),
+ )
+ return path
+
+
+class _RemoteFileIO:
+
+ def __init__(self, objects, directories=None, virtual_directories=False):
+ self.objects = dict(objects)
+ self.directories = dict(directories or {})
+ self.virtual_directories = virtual_directories
+ self.close_count = 0
+
+ def _get_fileio(self, path):
+ return self
+
+ def to_filesystem_path(self, path):
+ parsed = urlparse(path)
+ return "%s%s" % (parsed.netloc, parsed.path)
+
+ def get_file_status(self, path):
+ if path in self.objects:
+ return pafs.FileInfo(
+ path, pafs.FileType.File, size=len(self.objects[path]))
+ if path in self.directories and not self.virtual_directories:
+ return pafs.FileInfo(path, pafs.FileType.Directory)
+ raise FileNotFoundError(path)
+
+ def list_status(self, path):
+ result = []
+ for child in self.directories[path]:
+ if child in self.objects:
+ result.append(pafs.FileInfo(
+ child,
+ pafs.FileType.File,
+ size=len(self.objects[child]),
+ ))
+ else:
+ result.append(pafs.FileInfo(child, pafs.FileType.Directory))
+ return result
+
+ def new_input_stream(self, path):
+ return io.BytesIO(self.objects[path])
+
+ def close(self):
+ self.close_count += 1
+
+
+def test_rosbag_source_exposes_original_and_local_paths(tmp_path):
+ local_path = tmp_path / "recording.bag"
+
+ source = RosbagSource(
+ uri="s3://bucket/robot/recording.bag",
+ local_path=local_path,
+ format="ros1",
+ )
+
+ assert source.uri == "s3://bucket/robot/recording.bag"
+ assert source.local_path == Path(local_path)
+ assert source.format == "ros1"
+ assert source.name == "recording.bag"
+ assert source.stem == "recording"
+ assert source.is_remote is True
+
+
+def test_rosbag_public_types_are_exported():
+ assert pmm.RosbagSource is RosbagSource
+ assert pmm.RosbagLoadResult.__name__ == "RosbagLoadResult"
+ assert pmm.RosbagStagingConfig.__name__ == "RosbagStagingConfig"
+
+
[email protected](
+ "staging,match",
+ [
+ (RosbagStagingConfig(max_bytes=-1), "max_bytes"),
+ (RosbagStagingConfig(min_free_bytes=-1), "min_free_bytes"),
+ (RosbagStagingConfig(copy_buffer_bytes=0), "copy_buffer_bytes"),
+ ],
+)
+def test_rejects_invalid_staging_config(staging, match):
+ with pytest.raises(ValueError, match=match):
+ load_from_rosbag(
+ object(), [], transform=lambda reader, source: (),
+ staging=staging)
+
+
+def test_discovers_explicit_local_ros1_bag(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"rosbag")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources([bag], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].uri == bag.resolve().as_uri()
+ assert manifests[0].format == "ros1"
+ assert len(manifests[0].members) == 1
+ assert manifests[0].members[0].relative_path == "recording.bag"
+ assert manifests[0].members[0].size == 6
+
+
+def test_discovers_standalone_mcap_as_ros2(tmp_path):
+ mcap = tmp_path / "recording.mcap"
+ mcap.write_bytes(b"mcap")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources([mcap], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].format == "ros2_mcap"
+ assert manifests[0].members[0].relative_path == "recording.mcap"
+
+
+def test_standalone_db3_requires_explicit_fragment_mode(tmp_path):
+ database = tmp_path / "recording.db3"
+ database.write_bytes(b"sqlite")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="allow_storage_fragment=True"):
+ _discover_rosbag_sources([database], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_rejects_ros1_active_recording(tmp_path):
+ active = tmp_path / "recording.bag.active"
+ active.write_bytes(b"active")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="still being written"):
+ _discover_rosbag_sources([active], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_discovers_opted_in_standalone_db3_as_fragment(tmp_path):
+ database = tmp_path / "recording.db3"
+ database.write_bytes(b"sqlite")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources(
+ [database], source_file_io, allow_storage_fragment=True)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].format == "ros2_sqlite3_fragment"
+
+
+def test_rejects_corrupt_opted_in_sqlite_fragment_before_writer(tmp_path):
+ database = tmp_path / "recording.db3"
+ database.write_bytes(b"not-a-sqlite-database")
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder:
+ with pytest.raises(ValueError, match="SQLite integrity check failed"):
+ connection.load_from_rosbag(
+ table.identifier,
+ database,
+ transform=lambda reader, source: pa.table({"value": ["x"]}),
+ allow_storage_fragment=True,
+ )
+
+ new_write_builder.assert_not_called()
+
+
+def test_discovers_ros2_sqlite_directory_from_metadata(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].uri == recording.resolve().as_uri()
+ assert manifests[0].format == "ros2_sqlite3"
+ assert manifests[0].expected_message_count == 2
+ assert [member.relative_path for member in manifests[0].members] == [
+ "metadata.yaml", "recording.db3"]
+
+
+def test_rejects_ros2_sqlite_sidecar_as_not_finalized(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ database = next(recording.glob("*.db3"))
+ Path("%s-wal" % database).touch()
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="not finalized"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_detects_ros2_directory_member_added_after_discovery(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifest = _discover_rosbag_sources(
+ [recording], source_file_io)[0]
+ (recording / "new-split.db3").write_bytes(b"new")
+
+ with pytest.raises(ValueError, match="members changed"):
+ _verify_manifest_members(manifest, source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_detects_same_size_local_source_replacement(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"old")
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifest = _discover_rosbag_sources([bag], source_file_io)[0]
+ original_mtime_ns = bag.stat().st_mtime_ns
+ bag.write_bytes(b"new")
+ os.utime(
+ bag,
+ ns=(original_mtime_ns + 1_000_000_000,) * 2,
+ )
+
+ with pytest.raises(ValueError, match="source changed"):
+ _verify_manifest_members(manifest, source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_discovers_ros2_mcap_directory_from_metadata(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.MCAP)
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].format == "ros2_mcap"
+ assert [member.relative_path for member in manifests[0].members] == [
+ "metadata.yaml", "recording.mcap"]
+
+
+def
test_explicit_ros2_storage_directory_without_metadata_is_incomplete(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.MCAP)
+ (recording / "metadata.yaml").unlink()
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="missing metadata.yaml"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_recursively_discovers_sorts_and_deduplicates_mixed_sources(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ root = tmp_path / "sources"
+ root.mkdir()
+ ros1 = _write_ros1(root / "b.bag")
+ ros2 = _write_ros2(root / "nested" / "a", StoragePlugin.SQLITE3)
+ (root / "README.txt").write_text("ignored", encoding="utf-8")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ manifests = _discover_rosbag_sources(
+ [root, ros1, ros2], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert [(item.uri, item.format) for item in manifests] == [
+ (ros1.resolve().as_uri(), "ros1"),
+ (ros2.resolve().as_uri(), "ros2_sqlite3"),
+ ]
+
+
+def test_rejects_encoded_ros2_member_path_traversal(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+ from ruamel.yaml import YAML
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ metadata_path = recording / "metadata.yaml"
+ yaml = YAML()
+ metadata = yaml.load(metadata_path.read_text(encoding="utf-8"))
+ metadata["rosbag2_bagfile_information"]["relative_file_paths"] = [
+ "%252e%252e/escape.db3"]
+ with metadata_path.open("w", encoding="utf-8") as stream:
+ yaml.dump(metadata, stream)
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="unsafe.*relative_file_paths"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_rejects_duplicate_normalized_ros2_members(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+ from ruamel.yaml import YAML
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ metadata_path = recording / "metadata.yaml"
+ yaml = YAML()
+ metadata = yaml.load(metadata_path.read_text(encoding="utf-8"))
+ information = metadata["rosbag2_bagfile_information"]
+ member = information["relative_file_paths"][0]
+ information["relative_file_paths"] = [member, member.upper()]
+ with metadata_path.open("w", encoding="utf-8") as stream:
+ yaml.dump(metadata, stream)
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="duplicate.*relative_file_paths"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_rejects_nested_ros2_member_paths_unsupported_by_rosbags(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+ from ruamel.yaml import YAML
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ metadata_path = recording / "metadata.yaml"
+ yaml = YAML()
+ metadata = yaml.load(metadata_path.read_text(encoding="utf-8"))
+ information = metadata["rosbag2_bagfile_information"]
+ information["relative_file_paths"] = [
+ "nested/%s" % information["relative_file_paths"][0]]
+ with metadata_path.open("w", encoding="utf-8") as stream:
+ yaml.dump(metadata, stream)
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="nested.*relative_file_paths"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_rejects_malformed_ros2_metadata(tmp_path):
+ recording = tmp_path / "recording"
+ recording.mkdir()
+ (recording / "metadata.yaml").write_text("[", encoding="utf-8")
+ source_file_io = _SourceFileIO(Options({}))
+
+ try:
+ with pytest.raises(ValueError, match="Cannot read ROS2 metadata"):
+ _discover_rosbag_sources([recording], source_file_io)
+ finally:
+ source_file_io.close()
+
+
+def test_loads_ros1_after_preflight_into_one_snapshot(tmp_path):
+ bag = _write_ros1(tmp_path / "recording.bag")
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([
+ pa.field("source", pa.string(), nullable=False),
+ pa.field("timestamp", pa.int64(), nullable=False),
+ pa.field("value", pa.string(), nullable=False),
+ ]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ def transform(reader, source):
+ rows = []
+ for ros_connection, timestamp, rawdata in reader.messages():
+ message = reader.deserialize(rawdata, ros_connection.msgtype)
+ rows.append({
+ "source": source.name,
+ "timestamp": timestamp,
+ "value": message.data,
+ })
+ return pa.Table.from_pylist(rows)
+
+ result = connection.load_from_rosbag(
+ table.identifier, bag, transform=transform)
+
+ assert result.source_count == 1
+ assert result.batch_count == 1
+ assert result.row_count == 2
+ assert result.snapshot_id is not None
+ assert table.scan().to_arrow().select([
+ "source", "timestamp", "value"]
+ ).to_pylist() == [
+ {
+ "source": "recording.bag",
+ "timestamp": _START_NS,
+ "value": "first",
+ },
+ {
+ "source": "recording.bag",
+ "timestamp": _START_NS + 1,
+ "value": "second",
+ },
+ ]
+
+
+def test_loads_remote_ros1_through_fileio_staging(tmp_path):
+ local_bag = _write_ros1(tmp_path / "source.bag")
+ remote = _RemoteFileIO({
+ "bucket/recording.bag": local_bag.read_bytes(),
+ })
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([
+ pa.field("value", pa.string(), nullable=False),
+ ]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ def transform(reader, source):
+ values = [
+ reader.deserialize(rawdata, ros_connection.msgtype).data
+ for ros_connection, _, rawdata in reader.messages()
+ ]
+ return pa.table({"value": values})
+
+ with patch(
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
+ return_value=remote):
+ result = connection.load_from_rosbag(
+ table.identifier,
+ "s3://bucket/recording.bag",
+ transform=transform,
+ )
+
+ assert result.row_count == 2
+ assert table.scan().to_arrow().select(["value"]).to_pylist() == [
+ {"value": "first"}, {"value": "second"}]
+ assert remote.close_count == 1
+
+
+def test_loads_remote_ros2_directory_from_manifest_members(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ local = _write_ros2(tmp_path / "local", StoragePlugin.SQLITE3)
+ metadata = local / "metadata.yaml"
+ database = next(local.glob("*.db3"))
+ remote = _RemoteFileIO(
+ objects={
+ "bucket/recording/metadata.yaml": metadata.read_bytes(),
+ "bucket/recording/%s" % database.name: database.read_bytes(),
+ },
+ directories={
+ "bucket/recording": [
+ "bucket/recording/metadata.yaml",
+ "bucket/recording/%s" % database.name,
+ ],
+ },
+ )
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ def transform(reader, source):
+ return pa.table({"value": [
+ reader.deserialize(rawdata, ros_connection.msgtype).data
+ for ros_connection, _, rawdata in reader.messages()
+ ]})
+
+ with patch(
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
+ return_value=remote):
+ result = connection.load_from_rosbag(
+ table.identifier,
+ "s3://bucket/recording",
+ transform=transform,
+ )
+
+ assert result.source_count == 1
+ assert result.row_count == 2
+ assert table.scan().to_arrow().select(["value"]).to_pylist() == [
+ {"value": "first"}, {"value": "second"}]
+
+
+def test_discovers_remote_ros2_virtual_directory_prefix(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+
+ local = _write_ros2(tmp_path / "local", StoragePlugin.MCAP)
+ metadata = local / "metadata.yaml"
+ mcap = next(local.glob("*.mcap"))
+ remote = _RemoteFileIO(
+ objects={
+ "bucket/recording/metadata.yaml": metadata.read_bytes(),
+ "bucket/recording/%s" % mcap.name: mcap.read_bytes(),
+ },
+ directories={
+ "bucket/recording": [
+ "bucket/recording/metadata.yaml",
+ "bucket/recording/%s" % mcap.name,
+ ],
+ },
+ virtual_directories=True,
+ )
+
+ with patch(
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
+ return_value=remote):
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifests = _discover_rosbag_sources(
+ ["s3://bucket/recording"], source_file_io)
+ finally:
+ source_file_io.close()
+
+ assert len(manifests) == 1
+ assert manifests[0].format == "ros2_mcap"
+
+
+def test_remote_source_staging_is_removed_after_transform_scope(tmp_path):
+ local_bag = _write_ros1(tmp_path / "source.bag")
+ remote = _RemoteFileIO({
+ "bucket/recording.bag": local_bag.read_bytes(),
+ })
+ staging_root = tmp_path / "staging"
+
+ with patch(
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
+ return_value=remote):
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifest = _discover_rosbag_sources(
+ ["s3://bucket/recording.bag"], source_file_io)[0]
+ with _materialized_rosbag(
+ manifest,
+ source_file_io,
+ staging_root,
+ RosbagStagingConfig(min_free_bytes=0)) as local_path:
+ assert local_path.is_file()
+ assert not staging_root.exists()
+ finally:
+ source_file_io.close()
+
+
+def test_remote_staging_limit_stops_before_writing_excess_bytes(tmp_path):
+ class UnderreportedRemoteFileIO(_RemoteFileIO):
+
+ def get_file_status(self, path):
+ status = super().get_file_status(path)
+ return pafs.FileInfo(
+ status.path,
+ status.type,
+ size=1,
+ )
+
+ uri = "s3://bucket/recording.bag"
+ remote = UnderreportedRemoteFileIO({
+ "bucket/recording.bag": b"x" * 1024,
+ })
+ with patch(
+ "pypaimon.multimodal.source_utils.ResolvingFileIO",
+ return_value=remote):
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifest = _discover_rosbag_sources([uri], source_file_io)[0]
+ staging_root = tmp_path / "staging"
+ staging_root.mkdir()
+
+ with pytest.raises(ValueError, match="configured limit"):
+ _stage_remote_manifest(
+ manifest,
+ source_file_io,
+ staging_root,
+ RosbagStagingConfig(
+ max_bytes=1,
+ min_free_bytes=0,
+ copy_buffer_bytes=1024,
+ ),
+ )
+
+ staged = staging_root / "recording.bag"
+ assert staged.stat().st_size <= 1
+ finally:
+ source_file_io.close()
+
+
+def test_metadata_count_failure_happens_before_transform_and_writer(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+ from ruamel.yaml import YAML
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.SQLITE3)
+ metadata_path = recording / "metadata.yaml"
+ yaml = YAML()
+ metadata = yaml.load(metadata_path.read_text(encoding="utf-8"))
+ metadata["rosbag2_bagfile_information"]["message_count"] += 1
+ with metadata_path.open("w", encoding="utf-8") as stream:
+ yaml.dump(metadata, stream)
+
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+ transform = Mock()
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder:
+ with pytest.raises(ValueError, match="declared 3 messages.*2"):
+ connection.load_from_rosbag(
+ table.identifier, recording, transform=transform)
+
+ transform.assert_not_called()
+ new_write_builder.assert_not_called()
+
+
+def test_topic_count_failure_happens_before_transform_and_writer(tmp_path):
+ from rosbags.rosbag2 import StoragePlugin
+ from ruamel.yaml import YAML
+
+ recording = _write_ros2(
+ tmp_path / "recording", StoragePlugin.MCAP)
+ metadata_path = recording / "metadata.yaml"
+ yaml = YAML()
+ metadata = yaml.load(metadata_path.read_text(encoding="utf-8"))
+ topic = metadata["rosbag2_bagfile_information"][
+ "topics_with_message_count"][0]
+ topic["message_count"] += 1
+ with metadata_path.open("w", encoding="utf-8") as stream:
+ yaml.dump(metadata, stream)
+
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+ transform = Mock()
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder:
+ with pytest.raises(ValueError, match="topic /text declares 3.*2"):
+ connection.load_from_rosbag(
+ table.identifier, recording, transform=transform)
+
+ transform.assert_not_called()
+ new_write_builder.assert_not_called()
+
+
+def test_rechecks_all_sources_after_last_transform_before_writer(tmp_path):
+ first = _write_ros1(tmp_path / "a.bag", values=("first",))
+ second = _write_ros1(tmp_path / "b.bag", values=("second",))
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ def transform(reader, source):
+ values = [
+ reader.deserialize(rawdata, ros_connection.msgtype).data
+ for ros_connection, _, rawdata in reader.messages()
+ ]
+ if source.name == "b.bag":
+ with first.open("ab") as stream:
+ stream.write(b"changed")
+ return pa.table({"value": values})
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder:
+ with pytest.raises(ValueError, match="source changed"):
+ connection.load_from_rosbag(
+ table.identifier,
+ [first, second],
+ transform=transform,
+ )
+
+ new_write_builder.assert_not_called()
+
+
+def test_arrow_ipc_staging_limit_fails_before_writer(tmp_path):
+ bag = _write_ros1(tmp_path / "recording.bag", values=("value",))
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+
+ def transform(reader, source):
+ return pa.table({"value": ["larger-than-one-byte"]})
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder:
+ with pytest.raises(ValueError, match="staging exceeds.*1 bytes"):
+ connection.load_from_rosbag(
+ table.identifier,
+ bag,
+ transform=transform,
+ staging=RosbagStagingConfig(
+ max_bytes=1,
+ min_free_bytes=0,
+ ),
+ )
+
+ new_write_builder.assert_not_called()
+
+
+def test_bounded_staging_output_rejects_bytes_before_writing(tmp_path):
+ spool_path = tmp_path / "validated.arrow"
+
+ with _BoundedStagingOutput(spool_path, 1) as output:
+ with pytest.raises(ValueError, match="configured limit"):
+ output.write(b"too large")
+
+ assert spool_path.stat().st_size == 0
+
+
+def test_bounded_staging_output_reserves_remote_source_bytes(tmp_path):
+ spool_path = tmp_path / "validated.arrow"
+
+ with _BoundedStagingOutput(spool_path, 2) as output:
+ output.set_reserved_bytes(1)
+ output.write(b"x")
+ with pytest.raises(ValueError, match="configured limit"):
+ output.write(b"y")
+
+ assert spool_path.stat().st_size == 1
+
+
+def test_arrow_ipc_staging_requires_free_space_before_transform(tmp_path):
+ bag = _write_ros1(tmp_path / "recording.bag", values=("value",))
+ connection = pmm.connect(options={"warehouse": str(tmp_path /
"warehouse")})
+ table = connection.create_table(
+ "messages",
+ schema=pa.schema([pa.field("value", pa.string(), nullable=False)]),
+ options={"blob-as-descriptor": "false"},
+ )
+ transform = Mock(return_value=pa.table({"value": ["value"]}))
+
+ with patch.object(
+ connection, "get_table", return_value=table), patch.object(
+ table.raw_table,
+ "new_batch_write_builder") as new_write_builder, patch(
+ "pypaimon.multimodal.rosbag.loader.shutil.disk_usage",
+ return_value=Mock(free=0)):
+ with pytest.raises(ValueError, match="less than 1 free bytes"):
+ connection.load_from_rosbag(
+ table.identifier,
+ bag,
+ transform=transform,
+ staging=RosbagStagingConfig(min_free_bytes=1),
+ )
+
+ transform.assert_not_called()
+ new_write_builder.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/ray_rosbag_test.py
b/paimon-python/pypaimon/tests/ray_rosbag_test.py
new file mode 100644
index 0000000000..db033321a6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_rosbag_test.py
@@ -0,0 +1,251 @@
+# 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 pickle
+import sys
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.common.options import Options
+from pypaimon.multimodal.rosbag.api import RosbagStagingConfig
+from pypaimon.multimodal.rosbag.source import _discover_rosbag_sources
+from pypaimon.multimodal.source_utils import _SourceFileIO
+from pypaimon.ray.rosbag import _TransformRosbagSource, load_from_rosbag
+
+
+pytestmark = pytest.mark.skipif(
+ sys.version_info < (3, 10),
+ reason="rosbags 0.11 requires Python 3.10 or newer",
+)
+
+
+_MSGTYPE = "std_msgs/msg/String"
+
+
+def _write_ros1(path):
+ from rosbags.rosbag1 import Writer
+ from rosbags.typesys import Stores, get_typestore
+
+ typestore = get_typestore(Stores.ROS1_NOETIC)
+ with Writer(path) as writer:
+ connection = writer.add_connection(
+ "/text", _MSGTYPE, typestore=typestore)
+ message = typestore.types[_MSGTYPE](data="worker")
+ writer.write(
+ connection,
+ 1_700_000_000_000_000_000,
+ typestore.serialize_ros1(message, _MSGTYPE),
+ )
+ return path
+
+
+def test_ray_rosbag_loader_is_exported():
+ from pypaimon.ray import load_from_rosbag as exported
+
+ assert exported is load_from_rosbag
+
+
+def test_ray_rosbag_rejects_invalid_staging_before_catalog_access():
+ with patch(
+ "pypaimon.catalog.catalog_factory.CatalogFactory.create"
+ ) as create_catalog:
+ with pytest.raises(ValueError, match="copy_buffer_bytes"):
+ load_from_rosbag(
+ "default.messages",
+ [],
+ {"warehouse": "/tmp/warehouse"},
+ transform=lambda reader, source: (),
+ staging=RosbagStagingConfig(copy_buffer_bytes=0),
+ )
+
+ create_catalog.assert_not_called()
+
+
+def test_ray_rosbag_materializes_validation_before_paimon_write(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"bag")
+ lazy_dataset = Mock(name="lazy_dataset")
+ materialized_dataset = Mock(name="materialized_dataset")
+ lazy_dataset.materialize.return_value = materialized_dataset
+ inputs = Mock(name="inputs")
+ inputs.map_batches.return_value = lazy_dataset
+ ray_data = Mock(name="ray_data")
+ ray_data.from_items.return_value = inputs
+ table = Mock()
+ table.table_schema.to_arrow_schema.return_value = pa.schema([
+ pa.field("value", pa.int64(), nullable=False)])
+ catalog = Mock()
+ catalog.get_table.return_value = table
+ write_result = SimpleNamespace(row_count=3, snapshot_id=7)
+
+ with patch(
+ "pypaimon.ray.ray_paimon._require_ray_data",
+ return_value=ray_data), patch(
+ "pypaimon.catalog.catalog_factory.CatalogFactory.create",
+ return_value=catalog), patch(
+ "pypaimon.multimodal.table._target_schema",
+ return_value=pa.schema([
+ pa.field("value", pa.int64(), nullable=False)])),
patch(
+ "pypaimon.ray.ray_paimon.write_paimon",
+ return_value=write_result) as write_paimon:
+ result = load_from_rosbag(
+ "default.messages",
+ bag,
+ {"warehouse": str(tmp_path / "warehouse")},
+ transform=lambda reader, source: pa.table({"value": [1]}),
+ )
+
+ lazy_dataset.materialize.assert_called_once_with()
+ assert write_paimon.call_args.args[0] is materialized_dataset
+ assert result.source_count == 1
+ assert result.batch_count is None
+ assert result.row_count == 3
+ assert result.snapshot_id == 7
+
+
+def test_ray_rosbag_materialize_failure_never_starts_paimon_write(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"bag")
+ lazy_dataset = Mock()
+ lazy_dataset.materialize.side_effect = ValueError("invalid source")
+ inputs = Mock()
+ inputs.map_batches.return_value = lazy_dataset
+ ray_data = Mock()
+ ray_data.from_items.return_value = inputs
+ catalog = Mock()
+ catalog.get_table.return_value = Mock()
+
+ with patch(
+ "pypaimon.ray.ray_paimon._require_ray_data",
+ return_value=ray_data), patch(
+ "pypaimon.catalog.catalog_factory.CatalogFactory.create",
+ return_value=catalog), patch(
+ "pypaimon.multimodal.table._target_schema",
+ return_value=pa.schema([
+ pa.field("value", pa.int64(), nullable=False)])),
patch(
+ "pypaimon.ray.ray_paimon.write_paimon") as write_paimon:
+ with pytest.raises(ValueError, match="invalid source"):
+ load_from_rosbag(
+ "default.messages",
+ bag,
+ {"warehouse": str(tmp_path / "warehouse")},
+ transform=lambda reader, source: pa.table({"value": [1]}),
+ )
+
+ write_paimon.assert_not_called()
+
+
+def test_ray_rosbag_rechecks_sources_after_materialize_before_write(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"bag")
+ materialized_dataset = Mock()
+ lazy_dataset = Mock()
+
+ def materialize():
+ with bag.open("ab") as stream:
+ stream.write(b"changed")
+ return materialized_dataset
+
+ lazy_dataset.materialize.side_effect = materialize
+ inputs = Mock()
+ inputs.map_batches.return_value = lazy_dataset
+ ray_data = Mock()
+ ray_data.from_items.return_value = inputs
+ catalog = Mock()
+ catalog.get_table.return_value = Mock()
+
+ with patch(
+ "pypaimon.ray.ray_paimon._require_ray_data",
+ return_value=ray_data), patch(
+ "pypaimon.catalog.catalog_factory.CatalogFactory.create",
+ return_value=catalog), patch(
+ "pypaimon.multimodal.table._target_schema",
+ return_value=pa.schema([
+ pa.field("value", pa.int64(), nullable=False)])),
patch(
+ "pypaimon.ray.ray_paimon.write_paimon") as write_paimon:
+ with pytest.raises(ValueError, match="source changed"):
+ load_from_rosbag(
+ "default.messages",
+ bag,
+ {"warehouse": str(tmp_path / "warehouse")},
+ transform=lambda reader, source: pa.table({"value": [1]}),
+ )
+
+ write_paimon.assert_not_called()
+
+
+def test_ray_rosbag_worker_validates_and_transforms_one_source(tmp_path):
+ bag = _write_ros1(tmp_path / "recording.bag")
+ source_file_io = _SourceFileIO(Options({}))
+ try:
+ manifest = _discover_rosbag_sources([bag], source_file_io)[0]
+ finally:
+ source_file_io.close()
+ schema = pa.schema([pa.field("value", pa.string(), nullable=False)])
+
+ def transform(reader, source):
+ values = [
+ reader.deserialize(rawdata, connection.msgtype).data
+ for connection, _, rawdata in reader.messages()
+ ]
+ return pa.table({"value": values})
+
+ worker = _TransformRosbagSource(
+ transform=transform,
+ default_typestore=None,
+ typestore_factory=None,
+ source_options={},
+ staging=RosbagStagingConfig(min_free_bytes=0),
+ target_schema=schema,
+ )
+
+ output = list(worker(pa.table({
+ "manifest": [pickle.dumps(manifest)],
+ })))
+
+ assert len(output) == 1
+ assert output[0].schema == schema
+ assert output[0].to_pylist() == [{"value": "worker"}]
+
+
+def test_ray_rosbag_rejects_nonserializable_transform_on_driver(tmp_path):
+ bag = tmp_path / "recording.bag"
+ bag.write_bytes(b"bag")
+
+ class NonSerializableTransform:
+
+ def __init__(self):
+ self.values = (value for value in [1])
+
+ def __call__(self, reader, source):
+ return pa.table({"value": list(self.values)})
+
+ ray_data = Mock()
+ with patch(
+ "pypaimon.ray.ray_paimon._require_ray_data",
+ return_value=ray_data):
+ with pytest.raises(ValueError, match="transform must be
Ray-serializable"):
+ load_from_rosbag(
+ "default.messages",
+ bag,
+ {"warehouse": str(tmp_path / "warehouse")},
+ transform=NonSerializableTransform(),
+ )
+
+ ray_data.from_items.assert_not_called()
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 8f0e57edc2..12e24632bc 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -237,6 +237,10 @@ setup(
# HDF5 loading is explicitly guarded and documented as Python 3.8+.
'h5py>=3,<4; python_version>="3.8"',
],
+ 'rosbag': [
+ # rosbags is pure Python and does not require a ROS installation.
+ 'rosbags>=0.11.5,<0.12; python_version>="3.10"',
+ ],
'lerobot': [
# datasets 4.1+ may select PyArrow 21+, while PyPaimon currently
# supports PyArrow <20. Pandas 2.2.2+ supports NumPy 2.x selected