blob: 1ccb22ec8d24248579adadc2bfc59de9568f0043 [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.
from typing import Dict, Iterable, Optional
import pyarrow as pa
from pypaimon import CatalogFactory, Schema as PaimonSchema
from pypaimon.catalog.catalog import Catalog
from pypaimon.catalog.catalog_exception import (
DatabaseNotExistException,
TableAlreadyExistException,
TableNotExistException,
)
from pypaimon.multimodal.table import MultimodalTable, _to_arrow_table
_DEFAULT_OPTIONS = {
"row-tracking.enabled": "true",
"data-evolution.enabled": "true",
"blob-as-descriptor": "true",
"global-index.search-mode": "full",
}
_DEFAULT_DATABASE = Catalog.DEFAULT_DATABASE
def connect(
*,
database: str = _DEFAULT_DATABASE,
options: Optional[Dict[str, str]] = None):
"""Connect to a Paimon catalog through the multimodal facade."""
return MultimodalConnection(
_resolve_catalog(options),
database=database,
)
class MultimodalConnection:
"""High-level entry point backed by a Paimon catalog and database."""
def __init__(self, catalog, database: str = _DEFAULT_DATABASE):
if not database:
raise ValueError("database is required.")
self.catalog = catalog
self.database = database
def create_table(
self,
name: str,
data=None,
schema=None,
options: Optional[Dict[str, str]] = None,
partitioned: Optional[Iterable[str]] = None,
ignore_if_exists: bool = False):
"""Create a multimodal table and optionally add initial data."""
identifier = self._identifier(name)
already_exists = _table_exists(self.catalog, identifier)
if already_exists and ignore_if_exists:
try:
return self.get_table(name)
except (DatabaseNotExistException, TableNotExistException):
pass
try:
paimon_schema = _to_paimon_schema(
schema, data, options, partitioned)
_validate_multimodal_schema(paimon_schema, identifier)
except ValueError:
if ignore_if_exists:
try:
return self.get_table(name)
except (DatabaseNotExistException, TableNotExistException):
pass
raise
self._create_database_for(identifier)
created = False
try:
self.catalog.create_table(
identifier, paimon_schema, False)
created = True
except TableAlreadyExistException:
if not ignore_if_exists:
raise
table = self.get_table(name)
if data is not None and created:
table.add(data)
return table
def get_table(self, name: str):
identifier = self._identifier(name)
raw_table = self.catalog.get_table(identifier)
_validate_multimodal_table(raw_table, identifier)
return MultimodalTable(
self.catalog,
identifier,
raw_table,
)
def load_from_hdf5(
self,
table_name: str,
paths,
*,
transform,
source_options=None):
"""Load HDF5 transforms into an existing table as one append commit.
Repeating a call appends the rows again. A commit exception has an
unknown result and is not safe to retry without checking table state.
Source filesystem options are isolated from the target warehouse.
"""
from pypaimon.multimodal.hdf5 import load_from_hdf5
return load_from_hdf5(
self.get_table(table_name),
paths,
transform=transform,
source_options=source_options,
)
def load_from_lerobot(
self,
table_name: str,
source,
*,
batch_size: int = 1024,
options=None,
source_options=None,
tag_name=None,
parallelism=1,
dataset_name=None,
extra_dataset_fields=None,
schema_evolution=True) -> None:
"""Import LeRobot v3 sources using table_name as the group prefix.
Creates <table_name>_frames, <table_name>_episodes, and
<table_name>_datasets.
"""
from pypaimon.multimodal.lerobot import load_from_lerobot
load_from_lerobot(
self,
table_name,
source,
batch_size=batch_size,
options=options,
source_options=source_options,
tag_name=tag_name,
parallelism=parallelism,
dataset_name=dataset_name,
extra_dataset_fields=extra_dataset_fields,
schema_evolution=schema_evolution,
)
def append_to_lerobot(
self,
table_name: str,
source,
*,
batch_size: int = 1024,
source_options=None,
tag_name=None,
parallelism=1,
dataset_name=None,
extra_dataset_fields=None,
schema_evolution=True) -> None:
"""Append LeRobot v3 datasets to a source-local three-table group."""
from pypaimon.multimodal.lerobot import load_from_lerobot
load_from_lerobot(
self, table_name, source, batch_size=batch_size,
source_options=source_options, tag_name=tag_name,
parallelism=parallelism, dataset_name=dataset_name,
extra_dataset_fields=extra_dataset_fields,
schema_evolution=schema_evolution,
append=True,
)
def recover_lerobot_append(self, table_name: str):
"""Recover a three-table group after an interrupted append."""
from pypaimon.multimodal.lerobot.aggregate import _recover_incomplete_append
return _recover_incomplete_append(self, table_name)
def create_lerobot_tag(self, table_name: str, tag_name: str):
"""Pin a LeRobot group by prefix or frame table name.
Pause group writes until this returns. Use the tag only after success
and retain it on every component for the lifetime of a training run.
"""
from pypaimon.multimodal.lerobot.metadata import create_lerobot_tag
return create_lerobot_tag(self, table_name, tag_name)
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),
ignore_if_not_exists=ignore_if_not_exists,
)
def _identifier(self, name: str) -> str:
return _resolve_identifier(name, self.database)
def _create_database_for(self, identifier: str):
database_name = _database_name(identifier)
if database_name is not None:
self.catalog.create_database(database_name, ignore_if_exists=True)
def _resolve_catalog(options):
resolved_options = {}
if options:
resolved_options.update(options)
if not resolved_options:
raise ValueError("options is required.")
return CatalogFactory.create(resolved_options)
def _table_exists(catalog, identifier: str) -> bool:
try:
catalog.get_table(identifier)
return True
except (DatabaseNotExistException, TableNotExistException):
return False
def _validate_multimodal_table(table, identifier: str):
_validate_multimodal_schema(table.table_schema, identifier)
def _validate_multimodal_schema(table_schema, identifier: str):
options = table_schema.options
if str(options.get("data-evolution.enabled", "false")).lower() != "true":
raise ValueError(
"Table %s is not a data-evolution table; "
"data-evolution.enabled must be true." % identifier)
if table_schema.primary_keys:
raise ValueError(
"Table %s has primary keys %s; multimodal tables must not have "
"primary keys." % (identifier, table_schema.primary_keys))
def _resolve_identifier(name: str, database: str) -> str:
if not name:
raise ValueError("table name is required.")
if "." in name:
return name
return "%s.%s" % (database, name)
def _to_paimon_schema(schema, data, options, partitioned):
if schema is None:
if data is None:
raise ValueError("schema or data is required.")
pa_schema, inferred_options = _infer_pyarrow_schema(data)
else:
pa_schema = _to_pyarrow_schema(schema)
inferred_options = {}
return PaimonSchema.from_pyarrow_schema(
pa_schema,
partition_keys=_normalize_partitioned(partitioned),
options=_merge_options(inferred_options, options),
)
def _merge_options(*option_groups):
table_options = dict(_DEFAULT_OPTIONS)
for options in option_groups:
if options:
table_options.update({str(k): str(v) for k, v in options.items()})
return table_options
def _normalize_partitioned(partitioned):
if partitioned is None:
return []
if isinstance(partitioned, str):
return [partitioned]
return list(partitioned)
def _to_pyarrow_schema(schema):
if isinstance(schema, pa.Schema):
return schema
raise ValueError("schema must be a pyarrow.Schema.")
def _infer_pyarrow_schema(data):
table = _to_arrow_table(data)
if table.num_columns == 0:
raise ValueError("Cannot infer schema from empty data.")
return table.schema, {}
def _database_name(identifier: str):
parts = identifier.split(".")
if len(parts) == 2 and parts[0]:
return parts[0]
return None