Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2c5b4f9
[python] Add LeRobot v3 dataset import
XiaoHongbo-Hope Aug 28, 2026
09eddd5
[python] Support OSS sources in LeRobot import
XiaoHongbo-Hope Aug 28, 2026
cae41de
[python] Keep LeRobot schema validation isolated
XiaoHongbo-Hope Aug 28, 2026
deebb98
[python] Share strict Arrow import validation
XiaoHongbo-Hope Aug 28, 2026
e5e2b20
[python] Stream remote LeRobot imports through FileIO
XiaoHongbo-Hope Aug 28, 2026
226e888
[python] Fix LeRobot CI dependency ABI
XiaoHongbo-Hope Aug 30, 2026
97ab2f7
[python] Simplify LeRobot import docs
XiaoHongbo-Hope Aug 30, 2026
479b6f3
[python] Simplify LeRobot import API
XiaoHongbo-Hope Aug 30, 2026
2930718
[python] Simplify LeRobot import result
XiaoHongbo-Hope Aug 30, 2026
92933da
[python] Keep LeRobot image import independent
XiaoHongbo-Hope Aug 30, 2026
c97eefe
[python] Harden LeRobot source loading
XiaoHongbo-Hope Aug 30, 2026
7d6d11a
[python] Organize LeRobot importer as a package
XiaoHongbo-Hope Aug 30, 2026
0a7973e
[python] Harden LeRobot source isolation
XiaoHongbo-Hope Aug 30, 2026
798b079
[python] Move LeRobot import API out of package init
XiaoHongbo-Hope Aug 30, 2026
6809363
[python] Preserve LeRobot feature contracts
XiaoHongbo-Hope Aug 30, 2026
6a300b4
[python] Harden LeRobot import contracts
XiaoHongbo-Hope Aug 31, 2026
8f46dd7
[python] Validate LeRobot declared numeric ranges
XiaoHongbo-Hope Aug 31, 2026
db0831c
[python] Validate LeRobot metadata counts
XiaoHongbo-Hope Aug 31, 2026
dcf6347
[python] Reject cross-domain LeRobot values
XiaoHongbo-Hope Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/paimon-python-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ jobs:
fi
python -m pip install 'h5py>=3,<4'
python -c "import h5py; print('h5py', h5py.__version__)"
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__)"
fi

if [[ "${{ matrix.python-version }}" == "3.11" ]]; then
# Exercise the 0.4 API in one lane until its wheel is published.
Expand Down
42 changes: 42 additions & 0 deletions docs/docs/pypaimon/multimodal-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,48 @@ 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 LeRobot Dataset v3

`load_from_lerobot` imports a local directory, FileIO URI, or Hugging Face
repository. It derives the schema from `meta/info.json`, writes one row per
frame, and commits once.

```shell
pip install 'pypaimon[lerobot]'
```

```python
snapshot_id = conn.load_from_lerobot(
"robot_data",
"/data/lerobot_dataset",
)
print(snapshot_id)
```

The return value is `None` when the source has no frames.

For FileIO URIs, pass credentials through `source_options`:

```python
snapshot_id = conn.load_from_lerobot(
"robot_data",
"oss://source-bucket/lerobot_dataset",
source_options={
"fs.oss.endpoint": "oss-cn-hangzhou.aliyuncs.com",
"fs.oss.accessKeyId": "SOURCE_ACCESS_KEY_ID",
"fs.oss.accessKeySecret": "SOURCE_ACCESS_KEY_SECRET",
},
)
```

Missing tables are created from metadata; existing tables use strict schema
validation and append semantics. Scalars map to scalar types, vectors to
`VECTOR`, higher-rank tensors to nested `ARRAY`, and images to `BLOB`. Images
keep their compressed bytes.

Only v3 is supported. Video features, `uint64`, and language event structures
are rejected.

## Overwrite

`overwrite` accepts the same input formats as `add` and replaces existing data
Expand Down
23 changes: 23 additions & 0 deletions paimon-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,29 @@ pip3 install dist/*.tar.gz

The command will install the package and core dependencies to your local Python environment.

# Load LeRobot Dataset v3

Install the optional dependency, then import a local directory, FileIO URI, or
Hugging Face repository:

```commandline
pip install 'pypaimon[lerobot]'
```

```python
import pypaimon.multimodal as pmm

connection = pmm.connect(options={"warehouse": "/tmp/warehouse"})
snapshot_id = connection.load_from_lerobot(
"robot_data",
"/data/lerobot_dataset",
)
print(snapshot_id)
```

The schema comes from `meta/info.json`. Each frame becomes one row; media uses
BLOB columns. Missing tables are created and later calls append.

# HDF5 to multimodal tables

HDF5 loading requires Python 3.8 or newer. Install the optional dependency and
Expand Down
139 changes: 139 additions & 0 deletions paimon-python/pypaimon/multimodal/arrow_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 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.

"""Shared Arrow schema validation for multimodal format importers."""

import pyarrow as pa
import pyarrow.compute as pc


def strict_arrow_table(
data,
target_schema,
source_path,
batch_index,
format_name):
if isinstance(data, pa.RecordBatch):
table = pa.Table.from_batches([data])
elif isinstance(data, pa.Table):
table = data
else:
raise ValueError(
"%s transform must return Arrow data or an iterable of Arrow data."
% format_name)

missing = [
name for name in target_schema.names if name not in table.column_names
]
if missing:
raise ValueError(
"%s batch %d from %s is missing columns: %s"
% (format_name, batch_index, source_path, missing))
extra = [
name for name in table.column_names if name not in target_schema.names
]
if extra:
raise ValueError(
"%s batch %d from %s has unexpected columns: %s"
% (format_name, batch_index, source_path, extra))
if table.column_names != target_schema.names:
raise ValueError(
"%s batch %d from %s has columns in the wrong order: %s; "
"expected %s."
% (format_name, batch_index, source_path, table.column_names,
target_schema.names))
try:
_validate_nested_nullability(table, target_schema)
if table.schema.equals(target_schema, check_metadata=False):
return table
casted = table.cast(target_schema, safe=True)
_validate_nested_nullability(casted, target_schema)
return casted
except (ValueError, TypeError, NotImplementedError) as error:
raise ValueError(
"%s batch %d from %s cannot be converted to the table schema: %s"
% (format_name, batch_index, source_path, error)) from error


def _validate_nested_nullability(table, schema):
for field, column in zip(schema, table.columns):
for chunk in column.chunks:
_validate_array_nullability(chunk, field, field.name)


def _validate_array_nullability(array, field, path):
if not field.nullable and array.null_count:
raise ValueError(
"non-nullable field %s contains %d null value(s)"
% (path, array.null_count))

target_type = field.type
source_type = array.type
if (pa.types.is_list(target_type)
or pa.types.is_large_list(target_type)
or pa.types.is_fixed_size_list(target_type)):
if not (pa.types.is_list(source_type)
or pa.types.is_large_list(source_type)
or pa.types.is_fixed_size_list(source_type)):
return
_validate_array_nullability(
pc.list_flatten(array),
target_type.value_field,
"%s.%s" % (path, target_type.value_field.name),
)
return

if pa.types.is_map(target_type):
if not pa.types.is_map(source_type):
return
start = array.offsets[0].as_py()
stop = array.offsets[-1].as_py()
length = stop - start
offsets = pc.subtract(
array.offsets,
pa.scalar(start, type=array.offsets.type),
)
entries = pa.StructArray.from_arrays(
[array.keys.slice(start, length),
array.items.slice(start, length)],
fields=[source_type.key_field, source_type.item_field],
)
logical_entries = pc.list_flatten(pa.ListArray.from_arrays(
offsets,
entries,
mask=pc.is_null(array),
))
_validate_array_nullability(
logical_entries.field(0), target_type.key_field,
"%s.%s" % (path, target_type.key_field.name))
_validate_array_nullability(
logical_entries.field(1), target_type.item_field,
"%s.%s" % (path, target_type.item_field.name))
return

if pa.types.is_struct(target_type):
if not pa.types.is_struct(source_type):
return
parent_valid = pc.is_valid(array) if array.null_count else None
for index, child_field in enumerate(target_type):
child = array.field(index)
if parent_valid is not None:
child = pc.filter(child, parent_valid)
_validate_array_nullability(
child,
child_field,
"%s.%s" % (path, child_field.name),
)
19 changes: 19 additions & 0 deletions paimon-python/pypaimon/multimodal/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,25 @@ def load_from_hdf5(
source_options=source_options,
)

def load_from_lerobot(
self,
table_name: str,
source,
*,
batch_size: int = 1024,
options=None,
source_options=None):
"""Import LeRobot Dataset v3 and return the committed snapshot ID."""
from pypaimon.multimodal.lerobot import load_from_lerobot
return load_from_lerobot(
self,
table_name,
source,
batch_size=batch_size,
options=options,
source_options=source_options,
)

def drop_table(self, name: str, ignore_if_not_exists: bool = False):
self.catalog.drop_table(
self._identifier(name),
Expand Down
Loading
Loading