blob: 6d43c6d7759323ecab0bf3e23401881fcf85e1f9 [file]
# 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)