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 d557d0585a [python] Add LeRobot Dataset v3 import (#9446)
d557d0585a is described below

commit d557d0585ad0d9d57b36a1c498e983b2d9d8b42f
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Aug 31 11:35:44 2026 +0800

    [python] Add LeRobot Dataset v3 import (#9446)
---
 .github/workflows/paimon-python-checks.yml         |   4 +
 docs/docs/pypaimon/multimodal-api.mdx              |  42 ++
 paimon-python/README.md                            |  23 +
 paimon-python/pypaimon/multimodal/arrow_utils.py   | 139 ++++
 paimon-python/pypaimon/multimodal/connection.py    |  19 +
 paimon-python/pypaimon/multimodal/hdf5.py          | 172 +----
 .../pypaimon/multimodal/lerobot/__init__.py        |  24 +
 paimon-python/pypaimon/multimodal/lerobot/api.py   | 186 +++++
 .../pypaimon/multimodal/lerobot/loader.py          | 371 ++++++++++
 .../pypaimon/multimodal/lerobot/schema.py          | 170 +++++
 .../pypaimon/multimodal/lerobot/source.py          | 477 +++++++++++++
 paimon-python/pypaimon/multimodal/source_utils.py  |  67 ++
 .../pypaimon/tests/multimodal_lerobot_test.py      | 791 +++++++++++++++++++++
 paimon-python/setup.py                             |   8 +
 14 files changed, 2337 insertions(+), 156 deletions(-)

diff --git a/.github/workflows/paimon-python-checks.yml 
b/.github/workflows/paimon-python-checks.yml
index a7bdd6aa29..53ad2cf2a3 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 [[ "${{ 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
               # Run the RoboMIND pipeline tests with synthetic local HDF5 data.
diff --git a/docs/docs/pypaimon/multimodal-api.mdx 
b/docs/docs/pypaimon/multimodal-api.mdx
index cf672ca70a..e8732875b1 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -520,6 +520,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
diff --git a/paimon-python/README.md b/paimon-python/README.md
index 0e213985af..a008dbe260 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -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
diff --git a/paimon-python/pypaimon/multimodal/arrow_utils.py 
b/paimon-python/pypaimon/multimodal/arrow_utils.py
new file mode 100644
index 0000000000..76d056ed92
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/arrow_utils.py
@@ -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),
+            )
diff --git a/paimon-python/pypaimon/multimodal/connection.py 
b/paimon-python/pypaimon/multimodal/connection.py
index 51085910a4..3c55032b84 100644
--- a/paimon-python/pypaimon/multimodal/connection.py
+++ b/paimon-python/pypaimon/multimodal/connection.py
@@ -117,6 +117,25 @@ class MultimodalConnection:
             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),
diff --git a/paimon-python/pypaimon/multimodal/hdf5.py 
b/paimon-python/pypaimon/multimodal/hdf5.py
index acd85fb5d6..25583726cd 100644
--- a/paimon-python/pypaimon/multimodal/hdf5.py
+++ b/paimon-python/pypaimon/multimodal/hdf5.py
@@ -26,13 +26,18 @@ from typing import Callable, Mapping, Optional
 from urllib.parse import quote, unquote, urlparse, urlunparse
 
 import pyarrow as pa
-import pyarrow.compute as pc
 import pyarrow.fs as pafs
 
 from pypaimon.common.options import Options
 from pypaimon.filesystem.local_file_io import _file_uri_path
 from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
 from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
+from pypaimon.multimodal.arrow_utils import strict_arrow_table
+from pypaimon.multimodal.source_utils import (
+    _source_path_text,
+    _validated_source_options,
+    _validate_source_kerberos,
+)
 from pypaimon.multimodal.table import _target_schema
 from pypaimon.write.commit_callback import CommitCallback
 
@@ -118,6 +123,9 @@ class _Hdf5SourceFileIO:
         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()
 
@@ -376,17 +384,6 @@ def _raise_legacy_directory_listing_error(path, error):
         % path) from error
 
 
-def _source_path_text(value):
-    try:
-        path = os.fspath(value)
-    except TypeError as error:
-        raise ValueError(
-            "paths must contain only filesystem paths or URIs.") from error
-    if isinstance(path, bytes):
-        raise ValueError("paths must contain only filesystem paths or URIs.")
-    return path
-
-
 def _normalize_source_path(value):
     path = _source_path_text(value)
     parsed = urlparse(path)
@@ -459,40 +456,6 @@ def _path_values(paths):
             "paths must be a path or an iterable of paths.") from error
 
 
-def _validated_source_options(source_options):
-    if source_options is None:
-        return {}
-    if not isinstance(source_options, Mapping):
-        raise ValueError("source_options must be a mapping.")
-    return dict(source_options)
-
-
-def _validate_source_kerberos(paths, source_options):
-    source_principal = (
-        source_options.get("security.kerberos.login.principal")
-        or source_options.get("security.principal")
-    )
-    source_keytab = (
-        source_options.get("security.kerberos.login.keytab")
-        or source_options.get("security.keytab")
-    )
-    if not source_principal and not source_keytab:
-        return
-    if bool(source_principal) != bool(source_keytab):
-        raise ValueError(
-            "Source Kerberos principal and keytab must be both set or both "
-            "unset.")
-    if not any(
-            urlparse(_source_path_text(path)).scheme.lower()
-            in ("hdfs", "viewfs") for path in paths):
-        return
-    raise ValueError(
-        "HDF5 sources cannot use an explicit Kerberos keytab in a shared "
-        "process because kinit overwrites process-global credentials. "
-        "Run the load in a process-isolated worker with a pre-acquired "
-        "ticket cache and omit the source principal and keytab options.")
-
-
 def _require_seekable(stream, source):
     required = ("read", "seek", "tell")
     if any(not callable(getattr(stream, method, None)) for method in required):
@@ -511,116 +474,13 @@ def _require_seekable(stream, source):
 
 
 def _strict_arrow_table(data, target_schema, source, batch_index):
-    if isinstance(data, pa.RecordBatch):
-        table = pa.Table.from_batches([data])
-    elif isinstance(data, pa.Table):
-        table = data
-    else:
-        raise ValueError(
-            "HDF5 transform must return Arrow data or an iterable of Arrow 
data.")
-
-    missing = [
-        name for name in target_schema.names if name not in table.column_names
-    ]
-    if missing:
-        raise ValueError(
-            "HDF5 batch %d from %s is missing columns: %s"
-            % (batch_index, source.path, missing))
-    extra = [
-        name for name in table.column_names if name not in target_schema.names
-    ]
-    if extra:
-        raise ValueError(
-            "HDF5 batch %d from %s has unexpected columns: %s"
-            % (batch_index, source.path, extra))
-    if table.column_names != target_schema.names:
-        raise ValueError(
-            "HDF5 batch %d from %s has columns in the wrong order: %s; "
-            "expected %s."
-            % (batch_index, source.path, table.column_names,
-               target_schema.names))
-    try:
-        _validate_nested_nullability(table, target_schema)
-        if table.schema.equals(target_schema, check_metadata=False):
-            return table
-        casted = table.cast(target_schema, safe=True)
-        _validate_nested_nullability(casted, target_schema)
-        return casted
-    except (ValueError, TypeError, NotImplementedError) as error:
-        raise ValueError(
-            "HDF5 batch %d from %s cannot be converted to the table schema: %s"
-            % (batch_index, source.path, error)) from error
-
-
-def _validate_nested_nullability(table, schema):
-    for field, column in zip(schema, table.columns):
-        for chunk in column.chunks:
-            _validate_array_nullability(chunk, field, field.name)
-
-
-def _validate_array_nullability(array, field, path):
-    if not field.nullable and array.null_count:
-        raise ValueError(
-            "non-nullable field %s contains %d null value(s)"
-            % (path, array.null_count))
-
-    target_type = field.type
-    source_type = array.type
-    if (pa.types.is_list(target_type)
-            or pa.types.is_large_list(target_type)
-            or pa.types.is_fixed_size_list(target_type)):
-        if not (pa.types.is_list(source_type)
-                or pa.types.is_large_list(source_type)
-                or pa.types.is_fixed_size_list(source_type)):
-            return
-        _validate_array_nullability(
-            pc.list_flatten(array),
-            target_type.value_field,
-            "%s.%s" % (path, target_type.value_field.name),
-        )
-        return
-
-    if pa.types.is_map(target_type):
-        if not pa.types.is_map(source_type):
-            return
-        start = array.offsets[0].as_py()
-        stop = array.offsets[-1].as_py()
-        length = stop - start
-        offsets = pc.subtract(
-            array.offsets,
-            pa.scalar(start, type=array.offsets.type),
-        )
-        entries = pa.StructArray.from_arrays(
-            [array.keys.slice(start, length),
-             array.items.slice(start, length)],
-            fields=[source_type.key_field, source_type.item_field],
-        )
-        logical_entries = pc.list_flatten(pa.ListArray.from_arrays(
-            offsets,
-            entries,
-            mask=pc.is_null(array),
-        ))
-        _validate_array_nullability(
-            logical_entries.field(0), target_type.key_field,
-            "%s.%s" % (path, target_type.key_field.name))
-        _validate_array_nullability(
-            logical_entries.field(1), target_type.item_field,
-            "%s.%s" % (path, target_type.item_field.name))
-        return
-
-    if pa.types.is_struct(target_type):
-        if not pa.types.is_struct(source_type):
-            return
-        parent_valid = pc.is_valid(array) if array.null_count else None
-        for index, child_field in enumerate(target_type):
-            child = array.field(index)
-            if parent_valid is not None:
-                child = pc.filter(child, parent_valid)
-            _validate_array_nullability(
-                child,
-                child_field,
-                "%s.%s" % (path, child_field.name),
-            )
+    return strict_arrow_table(
+        data,
+        target_schema,
+        source.path,
+        batch_index,
+        "HDF5",
+    )
 
 
 def _arrow_batches(transformed):
diff --git a/paimon-python/pypaimon/multimodal/lerobot/__init__.py 
b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
new file mode 100644
index 0000000000..a40f2a8cce
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
@@ -0,0 +1,24 @@
+# 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.
+
+"""One-time LeRobot Dataset v3 import into a multimodal Paimon table."""
+
+from pypaimon.multimodal.lerobot.api import load_from_lerobot
+
+
+__all__ = [
+    "load_from_lerobot",
+]
diff --git a/paimon-python/pypaimon/multimodal/lerobot/api.py 
b/paimon-python/pypaimon/multimodal/lerobot/api.py
new file mode 100644
index 0000000000..0b7f76d78a
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/api.py
@@ -0,0 +1,186 @@
+# 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 LeRobot import API."""
+
+import numbers
+import sys
+from typing import Mapping, Optional
+
+import pyarrow as pa
+
+from pypaimon.catalog.catalog_exception import (
+    DatabaseNotExistException,
+    TableNotExistException,
+)
+from pypaimon.multimodal.lerobot.loader import (
+    _strict_lerobot_table,
+    _write_dataset,
+)
+from pypaimon.multimodal.lerobot.schema import (
+    _require_v3,
+    _schema_from_info,
+    _validate_lerobot_schema,
+)
+from pypaimon.multimodal.lerobot.source import (
+    _has_tasks,
+    _import_lerobot_dataset,
+    _load_hub_info,
+    _open_resolved_dataset,
+    _resolved_source,
+    _validate_info_paths,
+)
+from pypaimon.multimodal.source_utils import (
+    _validated_source_options,
+    _validate_source_kerberos,
+)
+from pypaimon.multimodal.table import _target_schema
+
+
+def load_from_lerobot(
+        connection,
+        table_name: str,
+        source,
+        *,
+        batch_size: int = 1024,
+        options: Optional[Mapping[str, object]] = None,
+        source_options: Optional[Mapping[str, object]] = None):
+    """Import LeRobot Dataset v3 and return the committed snapshot ID.
+
+    A missing target table is created from LeRobot metadata. An existing table
+    receives the same strict schema validation and append semantics as
+    :meth:`MultimodalConnection.load_from_hdf5`. FileIO URI credentials come
+    only from ``source_options`` and are not inherited from the target Catalog.
+    """
+    if sys.version_info < (3, 10):
+        raise RuntimeError(
+            "load_from_lerobot requires Python 3.10 or newer; install and "
+            "run 'pypaimon[lerobot]' on a supported Python version.")
+    if isinstance(batch_size, bool) or not isinstance(batch_size, int) \
+            or batch_size <= 0:
+        raise ValueError("batch_size must be a positive integer.")
+
+    validated_source_options = _validated_source_options(source_options)
+    _validate_source_kerberos(
+        [source], validated_source_options, "LeRobot")
+    with _resolved_source(source, validated_source_options) as (
+            resolved_source, local_info):
+        if local_info is None:
+            local_info = _load_hub_info(resolved_source)
+        _require_v3(local_info, resolved_source.path)
+        _validate_info_paths(local_info)
+        _schema_from_info(local_info, include_task=False)
+        total_frames, _, total_tasks = \
+            _validated_counts(local_info, resolved_source.path)
+        if total_frames == 0:
+            source_schema = _schema_from_info(
+                local_info,
+                include_task=total_tasks > 0,
+            )
+            _validated_table(
+                connection,
+                table_name,
+                source_schema,
+                options,
+                resolved_source,
+            )
+            return None
+        LeRobotDataset = _import_lerobot_dataset()
+        dataset = _open_resolved_dataset(
+            LeRobotDataset, resolved_source, local_info)
+        try:
+            info = dict(dataset.meta.info)
+            _require_v3(info, resolved_source.path)
+            row_count, _, _ = \
+                _validated_counts(info, resolved_source.path)
+
+            source_schema = _schema_from_info(
+                info, include_task=_has_tasks(dataset, info))
+            table = _validated_table(
+                connection,
+                table_name,
+                source_schema,
+                options,
+                resolved_source,
+            )
+
+            if row_count == 0:
+                return None
+            return _write_dataset(
+                table,
+                dataset,
+                info,
+                resolved_source,
+                source_schema,
+                batch_size,
+            )
+        finally:
+            close = getattr(dataset, "close", None)
+            if callable(close):
+                close()
+
+
+def _validated_counts(info, source):
+    total_frames = _required_count(info, "total_frames", source)
+    total_episodes = _required_count(info, "total_episodes", source)
+    total_tasks = _required_count(info, "total_tasks", source)
+    if (total_frames == 0) != (total_episodes == 0):
+        raise ValueError(
+            "LeRobot metadata %s has inconsistent counts: total_frames=%d "
+            "and total_episodes=%d must both be zero or both be positive."
+            % (source, total_frames, total_episodes))
+    return total_frames, total_episodes, total_tasks
+
+
+def _required_count(info, name, source):
+    if name not in info:
+        raise ValueError(
+            "LeRobot metadata %s is missing required field %s."
+            % (source, name))
+    value = info[name]
+    if isinstance(value, bool) or not isinstance(value, numbers.Integral) \
+            or value < 0:
+        raise ValueError(
+            "LeRobot metadata %s field %s must be a non-negative integer; "
+            "found %r." % (source, name, value))
+    return int(value)
+
+
+def _validated_table(
+        connection, table_name, source_schema, options, source):
+    table = _get_or_create_table(
+        connection, table_name, source_schema, options)
+    target_schema = _target_schema(table.raw_table)
+    _validate_lerobot_schema(
+        source_schema, target_schema, source.path)
+    _strict_lerobot_table(
+        pa.Table.from_batches([], schema=source_schema),
+        target_schema,
+        source,
+        0,
+    )
+    return table
+
+
+def _get_or_create_table(connection, table_name, schema, options):
+    try:
+        return connection.get_table(table_name)
+    except (DatabaseNotExistException, TableNotExistException):
+        return connection.create_table(
+            table_name,
+            schema=schema,
+            options=options,
+        )
diff --git a/paimon-python/pypaimon/multimodal/lerobot/loader.py 
b/paimon-python/pypaimon/multimodal/lerobot/loader.py
new file mode 100644
index 0000000000..1f46e047d2
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/loader.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.
+
+"""LeRobot frame conversion and batch writing."""
+
+import io
+import math
+import numbers
+from pathlib import Path
+
+import pyarrow as pa
+
+from pypaimon.multimodal.arrow_utils import strict_arrow_table
+from pypaimon.multimodal.hdf5 import _SnapshotRecorder
+from pypaimon.multimodal.lerobot.schema import _feature_shape
+from pypaimon.multimodal.table import _target_schema
+
+
+_DECLARED_NUMERIC_RANGES = {
+    "uint8": (0, 255),
+    "uint16": (0, 65535),
+    "uint32": (0, 4294967295),
+    "float16": (-65504.0, 65504.0),
+    "float32": (-3.4028234663852886e38, 3.4028234663852886e38),
+}
+_NUMERIC_DTYPES = {
+    "int8",
+    "int16",
+    "int32",
+    "int64",
+    "uint8",
+    "uint16",
+    "uint32",
+    "float16",
+    "float32",
+    "float64",
+}
+_BOOLEAN_DTYPES = {"bool", "boolean"}
+
+
+def _strict_lerobot_table(data, target_schema, source, batch_index):
+    return strict_arrow_table(
+        data,
+        target_schema,
+        source.path,
+        batch_index,
+        "LeRobot",
+    )
+
+
+def _write_dataset(
+        table,
+        dataset,
+        info,
+        source,
+        source_schema,
+        batch_size):
+    target_schema = _target_schema(table.raw_table)
+    write_builder = table.raw_table.new_batch_write_builder()
+    table_write = None
+    table_commit = None
+    commit_started = False
+    batch_count = 0
+    row_count = 0
+    snapshot_recorder = _SnapshotRecorder()
+
+    try:
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        table_commit.add_commit_callback(snapshot_recorder)
+        for begin, end in _episode_batches(dataset, info, batch_size):
+            batch = _read_batch(
+                dataset, info, begin, end, source_schema)
+            batch = _strict_lerobot_table(
+                batch,
+                target_schema,
+                source,
+                batch_count,
+            )
+            table_write.write_arrow(batch)
+            batch_count += 1
+            row_count += batch.num_rows
+
+        expected_rows = int(info.get("total_frames", len(dataset)))
+        if row_count != expected_rows:
+            raise ValueError(
+                "LeRobot metadata reports %d frames but import produced %d."
+                % (expected_rows, row_count))
+        messages = table_write.prepare_commit()
+        commit_started = True
+        table_commit.commit(messages)
+        if snapshot_recorder.snapshot_id is None:
+            raise RuntimeError(
+                "LeRobot 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()
+
+
+def _episode_batches(dataset, info, batch_size):
+    episodes = getattr(dataset.meta, "episodes", None)
+    episode_count = int(info.get("total_episodes", 0))
+    total_frames = int(info.get("total_frames", len(dataset)))
+    if episodes is None:
+        raise ValueError("LeRobot v3 metadata is missing episode boundaries.")
+    expected_begin = 0
+    for ordinal in range(episode_count):
+        episode = episodes.iloc[ordinal] if hasattr(episodes, "iloc") \
+            else episodes[ordinal]
+        begin = int(_python_scalar(episode["dataset_from_index"]))
+        end = int(_python_scalar(episode["dataset_to_index"]))
+        if begin != expected_begin or end <= begin:
+            raise ValueError(
+                "LeRobot episode %d has invalid frame range [%d, %d); "
+                "expected it to start at %d."
+                % (ordinal, begin, end, expected_begin))
+        while begin < end:
+            batch_end = min(begin + batch_size, end)
+            yield begin, batch_end
+            begin = batch_end
+        expected_begin = end
+    if expected_begin != total_frames:
+        raise ValueError(
+            "LeRobot episode ranges cover %d frames but metadata reports %d."
+            % (expected_begin, total_frames))
+
+
+def _read_batch(dataset, info, begin, end, schema):
+    read_batch = getattr(dataset, "read_batch", None)
+    if callable(read_batch):
+        raw = read_batch(begin, end)
+    else:
+        raw = dataset.hf_dataset.with_format("arrow")[begin:end]
+    if isinstance(raw, pa.RecordBatch):
+        raw = pa.Table.from_batches([raw])
+    elif not isinstance(raw, pa.Table):
+        raw = pa.Table.from_pydict(raw)
+    features = info["features"]
+
+    arrays = []
+    fields = []
+    for name, feature in features.items():
+        field = schema.field(name)
+        dtype = feature["dtype"]
+        if name not in raw.column_names:
+            raise ValueError(
+                "LeRobot data is missing metadata feature %s." % name)
+        values = raw.column(name).to_pylist()
+        if dtype == "image":
+            image_reader = getattr(dataset, "image_bytes", None)
+            if callable(image_reader):
+                values = [image_reader(value) for value in values]
+            else:
+                values = [_image_bytes(value, dataset.root)
+                          for value in values]
+        else:
+            values = [_normalize_value(value, feature, name)
+                      for value in values]
+        arrays.append(_safe_array(values, field, name, dtype))
+        fields.append(field)
+
+    if "task" in schema.names:
+        task_indices = raw.column("task_index").to_pylist()
+        arrays.append(pa.array(
+            [_task_name(dataset.meta.tasks, value) for value in task_indices],
+            type=pa.string(),
+        ))
+        fields.append(schema.field("task"))
+    return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
+
+
+def _safe_array(values, field, name, dtype):
+    _validate_declared_range(values, field.type, name, dtype)
+    try:
+        return pa.array(values).cast(field.type, safe=True)
+    except (pa.ArrowException, TypeError, ValueError, OverflowError) as error:
+        raise ValueError(
+            "LeRobot feature %s cannot be safely converted to %s: %s"
+            % (name, field.type, error)) from error
+
+
+def _validate_declared_range(values, target_type, name, dtype):
+    if (pa.types.is_list(target_type)
+            or pa.types.is_large_list(target_type)
+            or pa.types.is_fixed_size_list(target_type)):
+        for value in values:
+            if value is not None:
+                _validate_declared_range(
+                    value, target_type.value_type, name, dtype)
+        return
+
+    value_range = _DECLARED_NUMERIC_RANGES.get(dtype)
+    for value in values:
+        if value is None:
+            continue
+        _validate_value_domain(value, name, dtype)
+        if value_range is None:
+            continue
+        minimum, maximum = value_range
+        if dtype.startswith("float") and not isinstance(
+                value, numbers.Integral):
+            try:
+                if not math.isfinite(value):
+                    continue
+            except (TypeError, ValueError, OverflowError) as error:
+                raise ValueError(
+                    "LeRobot feature %s contains a value incompatible with "
+                    "dtype %s: %r" % (name, dtype, value)) from error
+        try:
+            out_of_range = value < minimum or value > maximum
+        except (TypeError, ValueError, OverflowError) as error:
+            raise ValueError(
+                "LeRobot feature %s contains a value incompatible with "
+                "dtype %s: %r" % (name, dtype, value)) from error
+        if out_of_range:
+            raise ValueError(
+                "LeRobot feature %s contains a value outside the %s "
+                "range [%s, %s]: %r"
+                % (name, dtype, minimum, maximum, value))
+
+
+def _validate_value_domain(value, name, dtype):
+    if dtype in _NUMERIC_DTYPES:
+        valid = isinstance(value, numbers.Real) \
+            and not isinstance(value, bool)
+        expected = "numeric"
+    elif dtype in _BOOLEAN_DTYPES:
+        valid = isinstance(value, bool)
+        expected = "boolean"
+    elif dtype == "string":
+        valid = isinstance(value, str)
+        expected = "string"
+    else:
+        return
+    if not valid:
+        raise ValueError(
+            "LeRobot feature %s declares dtype %s but contains a non-%s "
+            "value: %r" % (name, dtype, expected, value))
+
+
+def _normalize_value(value, feature, name):
+    shape = _feature_shape(feature, name)
+    if shape in ((), (1,)):
+        if isinstance(value, (list, tuple)):
+            if len(value) != 1:
+                raise ValueError(
+                    "LeRobot feature %s expected shape %s, got %s."
+                    % (name, shape, _value_shape(value)))
+            return value[0]
+        return _python_scalar(value)
+    actual_shape = _value_shape(value)
+    if actual_shape != shape:
+        raise ValueError(
+            "LeRobot feature %s expected shape %s, got %s."
+            % (name, shape, actual_shape))
+    return value
+
+
+def _value_shape(value):
+    if hasattr(value, "shape"):
+        return tuple(int(size) for size in value.shape)
+    if isinstance(value, (list, tuple)):
+        if not value:
+            return (0,)
+        child = _value_shape(value[0])
+        if any(_value_shape(item) != child for item in value[1:]):
+            return (len(value), -1)
+        return (len(value),) + child
+    return ()
+
+
+def _python_scalar(value):
+    item = getattr(value, "item", None)
+    if callable(item):
+        return item()
+    return value
+
+
+def _image_bytes(value, root):
+    if value is None:
+        raise ValueError("LeRobot image feature contains a null frame.")
+    if isinstance(value, (bytes, bytearray, memoryview)):
+        return bytes(value)
+    if isinstance(value, dict):
+        body = value.get("bytes")
+        if body is not None:
+            return bytes(body)
+        image_path = value.get("path")
+        if image_path:
+            root = Path(root).resolve()
+            path = Path(image_path)
+            if not path.is_absolute():
+                path = root / path
+            path = path.resolve()
+            try:
+                path.relative_to(root)
+            except ValueError as error:
+                raise ValueError(
+                    "LeRobot image path must stay within the source "
+                    "directory: %s" % image_path) from error
+            return path.read_bytes()
+    return _encode_media_frame(value)
+
+
+def _encode_media_frame(value):
+    try:
+        import numpy as np
+        from PIL import Image
+    except ImportError as error:
+        raise ImportError(
+            "LeRobot media import requires numpy and Pillow from the "
+            "'pypaimon[lerobot]' extra.") from error
+
+    if isinstance(value, Image.Image):
+        image = value
+    else:
+        detach = getattr(value, "detach", None)
+        if callable(detach):
+            value = detach().cpu().numpy()
+        array = np.asarray(value)
+        if array.ndim == 3 and array.shape[0] in (1, 3, 4):
+            array = np.transpose(array, (1, 2, 0))
+        if np.issubdtype(array.dtype, np.floating):
+            array = np.rint(np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
+        if array.ndim == 3 and array.shape[2] == 1:
+            array = array[:, :, 0]
+        try:
+            image = Image.fromarray(array)
+        except (KeyError, TypeError, ValueError) as error:
+            raise ValueError(
+                "Unsupported LeRobot media frame shape or dtype: %s, %s."
+                % (array.shape, array.dtype)) from error
+    output = io.BytesIO()
+    image.save(output, format="PNG")
+    return output.getvalue()
+
+
+def _task_name(tasks, task_index):
+    index = int(_python_scalar(task_index))
+    if index < 0 or index >= len(tasks):
+        raise ValueError(
+            "LeRobot task_index %d is outside [0, %d)."
+            % (index, len(tasks)))
+    if hasattr(tasks, "iloc"):
+        return str(tasks.iloc[index].name)
+    task = tasks[index]
+    if isinstance(task, dict):
+        return str(task.get("task", task.get("name")))
+    return str(task)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/schema.py 
b/paimon-python/pypaimon/multimodal/lerobot/schema.py
new file mode 100644
index 0000000000..6b8ccfb8b5
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/schema.py
@@ -0,0 +1,170 @@
+# 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.
+
+"""LeRobot metadata validation and Arrow schema conversion."""
+
+import json
+
+import pyarrow as pa
+
+
+_SCALAR_DTYPES = {
+    "bool": pa.bool_(),
+    "boolean": pa.bool_(),
+    "int8": pa.int8(),
+    "int16": pa.int16(),
+    "int32": pa.int32(),
+    "int64": pa.int64(),
+    "uint8": pa.int16(),
+    "uint16": pa.int32(),
+    "uint32": pa.int64(),
+    "float16": pa.float32(),
+    "float32": pa.float32(),
+    "float64": pa.float64(),
+    "string": pa.string(),
+}
+
+
+def _require_v3(info, source):
+    version = str(info.get("codebase_version", ""))
+    if not (version == "v3" or version.startswith("v3.")):
+        raise ValueError(
+            "load_from_lerobot supports LeRobot Dataset v3 only; %s reports "
+            "codebase_version=%r. Upgrade the dataset to v3 first."
+            % (source, version or None))
+
+
+def _schema_from_info(info, include_task):
+    features = info.get("features")
+    if not isinstance(features, dict) or not features:
+        raise ValueError("LeRobot metadata features must be a non-empty 
object.")
+
+    fields = []
+    for name, feature in features.items():
+        fields.append(_feature_field(name, feature))
+    if include_task:
+        fields.append(pa.field(
+            "task",
+            pa.string(),
+            nullable=False,
+            metadata={b"description": b"LeRobot task"},
+        ))
+    return pa.schema(fields)
+
+
+def _validate_lerobot_schema(source_schema, target_schema, source):
+    """Require an existing table to preserve the LeRobot feature contract."""
+    for source_field in source_schema:
+        target_index = target_schema.get_field_index(source_field.name)
+        if target_index < 0:
+            # The shared schema validator reports missing columns consistently.
+            continue
+        target_field = target_schema.field(target_index)
+        if source_field.type != target_field.type:
+            raise ValueError(
+                "LeRobot feature %s from %s cannot be converted to the "
+                "table schema: expected %s, found %s."
+                % (source_field.name, source, source_field.type,
+                   target_field.type))
+
+        source_description = _description(source_field)
+        if not source_description.startswith("LeRobot dtype="):
+            continue
+        target_description = _description(target_field)
+        if target_description != source_description:
+            raise ValueError(
+                "LeRobot feature %s from %s cannot be converted to the "
+                "table schema: expected %s, found %s."
+                % (source_field.name, source, source_description,
+                   target_description or "no LeRobot feature metadata"))
+
+
+def _description(field):
+    if not field.metadata:
+        return ""
+    return field.metadata.get(b"description", b"").decode("utf-8")
+
+
+def _feature_field(name, feature):
+    if not isinstance(feature, dict):
+        raise ValueError(
+            "LeRobot feature %s metadata must be an object." % name)
+    dtype = str(feature.get("dtype", ""))
+    shape = _feature_shape(feature, name)
+    if dtype == "video":
+        raise ValueError(
+            "LeRobot video feature %s is not supported yet; use an "
+            "image-based dataset." % name)
+    if dtype == "image":
+        arrow_type = pa.large_binary()
+    else:
+        scalar_type = _SCALAR_DTYPES.get(dtype)
+        if scalar_type is None:
+            suffix = " (uint64 has no lossless Paimon integer mapping)" \
+                if dtype == "uint64" else ""
+            raise ValueError(
+                "Unsupported LeRobot dtype %r for feature %s%s."
+                % (dtype, name, suffix))
+        if pa.types.is_string(scalar_type) and shape not in ((), (1,)):
+            raise ValueError(
+                "LeRobot string feature %s must be scalar." % name)
+        arrow_type = _tensor_type(scalar_type, shape)
+    description = "LeRobot dtype=%s, shape=%s, names=%s" % (
+        dtype,
+        json.dumps(list(shape), separators=(",", ":")),
+        json.dumps(
+            feature.get("names"),
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        ),
+    )
+    return pa.field(
+        name,
+        arrow_type,
+        nullable=False,
+        metadata={b"description": description.encode("utf-8")},
+    )
+
+
+def _feature_shape(feature, name):
+    shape = feature.get("shape", ())
+    if shape is None:
+        shape = ()
+    if not isinstance(shape, (list, tuple)):
+        raise ValueError(
+            "LeRobot feature %s has an invalid shape: %r" % (name, shape))
+    try:
+        result = tuple(int(size) for size in shape)
+    except (TypeError, ValueError) as error:
+        raise ValueError(
+            "LeRobot feature %s has an invalid shape: %r"
+            % (name, shape)) from error
+    if any(size <= 0 for size in result):
+        raise ValueError(
+            "LeRobot feature %s has an invalid shape: %r" % (name, shape))
+    return result
+
+
+def _tensor_type(scalar_type, shape):
+    if shape in ((), (1,)):
+        return scalar_type
+    if len(shape) == 1:
+        return pa.list_(scalar_type, shape[0])
+    result = pa.list_(scalar_type, shape[-1])
+    for unused_size in reversed(shape[1:-1]):
+        result = pa.list_(result)
+    return pa.list_(result)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/source.py 
b/paimon-python/pypaimon/multimodal/lerobot/source.py
new file mode 100644
index 0000000000..eaf995d69e
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/source.py
@@ -0,0 +1,477 @@
+# 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.
+
+"""LeRobot source resolution for local, Hub, and FileIO datasets."""
+
+import json
+import posixpath
+from bisect import bisect_right
+from contextlib import closing, contextmanager
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional
+from urllib.parse import quote, unquote, urlparse, urlunparse
+
+import pyarrow as pa
+import pyarrow.fs as pafs
+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,
+    _normalize_source_path,
+    _qualified_status_path,
+)
+from pypaimon.multimodal.lerobot.loader import _encode_media_frame
+
+
+@dataclass(frozen=True)
+class _LeRobotSource:
+    path: str
+    root: Optional[Path]
+    repo_id: str
+    file_io: object = None
+
+
+@dataclass(frozen=True)
+class _RemoteLeRobotMeta:
+    info: dict
+    episodes: list
+    tasks: list
+
+
+@contextmanager
+def _resolved_source(source, source_options):
+    if isinstance(source, Path):
+        root = source.expanduser().resolve()
+        if not root.is_dir():
+            raise FileNotFoundError(
+                "LeRobot source directory does not exist: %s" % root)
+        yield _local_source(root)
+        return
+    if not isinstance(source, str) or not source.strip():
+        raise ValueError(
+            "source must be a local directory or Hugging Face repo_id.")
+
+    value = source.strip()
+    candidate = Path(value).expanduser()
+    if candidate.is_dir():
+        yield _local_source(candidate.resolve())
+        return
+    if candidate.is_absolute() or value.startswith((".", "~")):
+        raise FileNotFoundError(
+            "LeRobot source directory does not exist: %s" % candidate)
+    if "://" not in value:
+        yield _LeRobotSource(path=value, root=None, repo_id=value), None
+        return
+
+    source_uri = _normalize_source_path(value).rstrip("/")
+    source_file_io = _Hdf5SourceFileIO(Options(source_options))
+    try:
+        try:
+            status = source_file_io.get_file_status(source_uri)
+        except FileNotFoundError as error:
+            raise FileNotFoundError(
+                "LeRobot source directory does not exist: %s" % source_uri
+            ) from error
+        if status.type != pafs.FileType.Directory:
+            raise ValueError(
+                "LeRobot URI source must be a directory: %s" % source_uri)
+        info = _read_remote_json(
+            source_file_io, _remote_path(source_uri, "meta/info.json"))
+        yield (
+            _LeRobotSource(
+                path=source_uri,
+                root=None,
+                repo_id="",
+                file_io=source_file_io,
+            ),
+            info,
+        )
+    finally:
+        source_file_io.close()
+
+
+def _local_source(root, display_path=None):
+    info_path = root / "meta" / "info.json"
+    if not info_path.is_file():
+        raise ValueError(
+            "LeRobot source is missing meta/info.json: %s"
+            % (display_path or root))
+    try:
+        with info_path.open("r", encoding="utf-8") as file:
+            info = json.load(file)
+    except (OSError, ValueError) as error:
+        raise ValueError(
+            "Cannot read LeRobot metadata %s: %s"
+            % (info_path, error)) from error
+    return (
+        _LeRobotSource(
+            path=str(display_path or root),
+            root=root,
+            repo_id="local/pypaimon-import",
+        ),
+        info,
+    )
+
+
+def _import_lerobot_dataset():
+    try:
+        from lerobot.datasets.lerobot_dataset import LeRobotDataset
+    except ImportError as error:
+        raise ImportError(
+            "load_from_lerobot requires LeRobot; install "
+            "'pypaimon[lerobot]'.") from error
+    return LeRobotDataset
+
+
+def _load_hub_info(source):
+    try:
+        from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata
+    except ImportError as error:
+        raise ImportError(
+            "load_from_lerobot requires LeRobot; install "
+            "'pypaimon[lerobot]'.") from error
+    try:
+        return dict(LeRobotDatasetMetadata(repo_id=source.repo_id).info)
+    except Exception as error:
+        raise ValueError(
+            "Cannot open LeRobot Dataset v3 metadata %s: %s"
+            % (source.path, error)) from error
+
+
+def _open_dataset(LeRobotDataset, source):
+    try:
+        if source.root is not None:
+            return LeRobotDataset(
+                repo_id=source.repo_id,
+                root=source.root,
+                download_videos=False,
+            )
+        return LeRobotDataset(
+            repo_id=source.repo_id,
+            download_videos=False,
+        )
+    except Exception as error:
+        raise ValueError(
+            "Cannot open LeRobot Dataset v3 source %s: %s"
+            % (source.path, error)) from error
+
+
+def _open_resolved_dataset(LeRobotDataset, source, info):
+    if source.file_io is not None:
+        return _RemoteLeRobotDataset(source, info)
+    return _open_dataset(LeRobotDataset, source)
+
+
+class _RemoteLeRobotDataset:
+
+    _EPISODE_COLUMNS = [
+        "episode_index",
+        "dataset_from_index",
+        "dataset_to_index",
+        "data/chunk_index",
+        "data/file_index",
+    ]
+
+    def __init__(self, source, info):
+        self.source = source
+        self.root = source.path
+        self._file_io = source.file_io
+        self._episodes = self._load_episodes(info)
+        self._tasks = self._load_tasks(info)
+        self.meta = _RemoteLeRobotMeta(info, self._episodes, self._tasks)
+        self._episode_starts = [
+            int(episode["dataset_from_index"])
+            for episode in self._episodes
+        ]
+        self._data_ranges = self._build_data_ranges(info)
+        self._cached_data_path = None
+        self._cached_data_table = None
+
+    def __len__(self):
+        return int(self.meta.info.get("total_frames", 0))
+
+    def close(self):
+        self._cached_data_table = None
+
+    def read_batch(self, begin, end):
+        episode = self._episode_for_range(begin, end)
+        relative_path = self._data_path(episode, self.meta.info)
+        source_path = _remote_source_path(
+            self.source.path,
+            relative_path,
+            "info.data_path",
+            self._file_io,
+        )
+        if source_path != self._cached_data_path:
+            table = _read_remote_parquet(self._file_io, source_path)
+            expected_begin, expected_end = self._data_ranges[relative_path]
+            expected_rows = expected_end - expected_begin
+            if table.num_rows != expected_rows:
+                raise ValueError(
+                    "LeRobot data file %s has %d rows; metadata expects %d."
+                    % (source_path, table.num_rows, expected_rows))
+            self._cached_data_path = source_path
+            self._cached_data_table = table
+        file_begin = self._data_ranges[relative_path][0]
+        return self._cached_data_table.slice(begin - file_begin, end - begin)
+
+    def image_bytes(self, value):
+        if value is None:
+            raise ValueError("LeRobot image feature contains a null frame.")
+        if isinstance(value, (bytes, bytearray, memoryview)):
+            return bytes(value)
+        if isinstance(value, dict):
+            body = value.get("bytes")
+            if body is not None:
+                return bytes(body)
+            image_path = value.get("path")
+            if image_path:
+                source_path = _remote_source_path(
+                    self.source.path,
+                    image_path,
+                    "image path",
+                    self._file_io,
+                )
+                return _read_remote_bytes(self._file_io, source_path)
+        return _encode_media_frame(value)
+
+    def _load_episodes(self, info):
+        episode_count = int(info.get("total_episodes", 0))
+        if episode_count == 0:
+            return []
+        directory = _remote_path(self.source.path, "meta/episodes")
+        paths = _remote_parquet_files(self._file_io, directory)
+        rows = []
+        for path in paths:
+            rows.extend(_read_remote_parquet(
+                self._file_io,
+                path,
+                columns=self._EPISODE_COLUMNS,
+            ).to_pylist())
+        rows.sort(key=lambda row: int(row["episode_index"]))
+        if len(rows) != episode_count:
+            raise ValueError(
+                "LeRobot metadata reports %d Episodes but %d were found."
+                % (episode_count, len(rows)))
+        return rows
+
+    def _load_tasks(self, info):
+        task_count = int(info.get("total_tasks", 0))
+        if task_count == 0:
+            return []
+        path = _remote_path(self.source.path, "meta/tasks.parquet")
+        rows = _read_remote_parquet(self._file_io, path).to_pylist()
+        tasks = [None] * task_count
+        for row in rows:
+            index = int(row["task_index"])
+            name = row.get("__index_level_0__")
+            if name is None:
+                name = row.get("task", row.get("name"))
+            if index < 0 or index >= task_count or name is None:
+                raise ValueError("LeRobot task metadata is invalid: %s" % row)
+            tasks[index] = str(name)
+        if any(task is None for task in tasks):
+            raise ValueError(
+                "LeRobot metadata reports %d tasks but %d were found."
+                % (task_count, len(rows)))
+        return tasks
+
+    def _build_data_ranges(self, info):
+        ranges = {}
+        for episode in self._episodes:
+            path = self._data_path(episode, info)
+            begin = int(episode["dataset_from_index"])
+            end = int(episode["dataset_to_index"])
+            if path in ranges:
+                previous_begin, previous_end = ranges[path]
+                if begin != previous_end:
+                    raise ValueError(
+                        "LeRobot data file %s has non-contiguous Episode "
+                        "ranges." % path)
+                ranges[path] = previous_begin, end
+            else:
+                ranges[path] = begin, end
+        return ranges
+
+    def _episode_for_range(self, begin, end):
+        index = bisect_right(self._episode_starts, begin) - 1
+        if index < 0:
+            raise ValueError("LeRobot frame range starts before Episode 0.")
+        episode = self._episodes[index]
+        episode_end = int(episode["dataset_to_index"])
+        if end > episode_end:
+            raise ValueError("LeRobot frame batch crosses an Episode 
boundary.")
+        return episode
+
+    @staticmethod
+    def _data_path(episode, info):
+        path = info["data_path"].format(
+            chunk_index=int(episode["data/chunk_index"]),
+            file_index=int(episode["data/file_index"]),
+        )
+        return _relative_dataset_path(path, "info.data_path")
+
+
+def _remote_path(root, relative_path):
+    return "%s/%s" % (root.rstrip("/"), relative_path.lstrip("/"))
+
+
+def _relative_dataset_path(path, name):
+    if not isinstance(path, str) or not path:
+        raise ValueError("LeRobot %s must be a relative path." % name)
+    path = _fully_unquote(path)
+    if "\\" in path or urlparse(path).scheme or path.startswith(("/", "~")):
+        raise ValueError(
+            "LeRobot %s must stay within the source directory: %s"
+            % (name, path))
+    normalized = posixpath.normpath(path)
+    if normalized in (".", "..") or normalized.startswith("../"):
+        raise ValueError(
+            "LeRobot %s must stay within the source directory: %s"
+            % (name, path))
+    return normalized
+
+
+def _remote_source_path(root, path, name, source_file_io=None):
+    if not isinstance(path, str) or not path:
+        _relative_dataset_path(path, name)
+    root_uri = urlparse(root)
+    path_uri = urlparse(path)
+    root_path = posixpath.normpath(_fully_unquote(root_uri.path) or "/")
+    if path_uri.scheme:
+        if path_uri.query or path_uri.fragment \
+                or path_uri.scheme.lower() != root_uri.scheme.lower() \
+                or path_uri.netloc != root_uri.netloc:
+            raise ValueError(
+                "LeRobot %s must stay within the source directory: %s"
+                % (name, path))
+        source_path = posixpath.normpath(
+            _fully_unquote(path_uri.path) or "/")
+    else:
+        relative_path = _relative_dataset_path(path, name)
+        source_path = posixpath.normpath(posixpath.join(
+            root_path,
+            relative_path,
+        ))
+    if posixpath.commonpath([root_path, source_path]) != root_path:
+        raise ValueError(
+            "LeRobot %s must stay within the source directory: %s"
+            % (name, path))
+    source_uri = urlunparse((
+        root_uri.scheme,
+        root_uri.netloc,
+        quote(source_path, safe="/:"),
+        "",
+        "",
+        "",
+    ))
+    _validate_filesystem_containment(
+        source_file_io, root, source_uri, name, path)
+    return source_uri
+
+
+def _fully_unquote(path):
+    decoded = unquote(path)
+    while decoded != path:
+        path = decoded
+        decoded = unquote(path)
+    return decoded
+
+
+def _validate_filesystem_containment(
+        source_file_io, root, source, name, original_path):
+    to_filesystem_path = getattr(
+        source_file_io, "to_filesystem_path", None)
+    if not callable(to_filesystem_path):
+        return
+    root_path = _fully_unquote(to_filesystem_path(root))
+    source_path = _fully_unquote(to_filesystem_path(source))
+    if urlparse(root).scheme.lower() == "file":
+        root_path = Path(root_path).resolve()
+        source_path = Path(source_path).resolve()
+        try:
+            source_path.relative_to(root_path)
+        except ValueError as error:
+            raise ValueError(
+                "LeRobot %s must stay within the source directory: %s"
+                % (name, original_path)) from error
+        return
+    else:
+        root_path = posixpath.normpath(root_path)
+        source_path = posixpath.normpath(source_path)
+    relative_path = posixpath.relpath(source_path, root_path)
+    if relative_path == ".." or relative_path.startswith("../"):
+        raise ValueError(
+            "LeRobot %s must stay within the source directory: %s"
+            % (name, original_path))
+
+
+def _validate_info_paths(info):
+    for name in ("data_path", "video_path"):
+        path = info.get(name)
+        if path is not None:
+            _relative_dataset_path(path, "info.%s" % name)
+
+
+def _read_remote_bytes(source_file_io, path):
+    stream = source_file_io.new_input_stream(path)
+    with closing(stream) as source_stream:
+        return source_stream.read()
+
+
+def _read_remote_json(source_file_io, path):
+    try:
+        return json.loads(_read_remote_bytes(
+            source_file_io, path).decode("utf-8"))
+    except (OSError, UnicodeError, ValueError) as error:
+        raise ValueError(
+            "Cannot read LeRobot metadata %s: %s" % (path, error)) from error
+
+
+def _read_remote_parquet(source_file_io, path, columns=None):
+    stream = source_file_io.new_input_stream(path)
+    with closing(stream) as source_stream:
+        try:
+            return pq.read_table(source_stream, columns=columns)
+        except (OSError, ValueError, pa.ArrowException) as error:
+            raise ValueError(
+                "Cannot read LeRobot Parquet file %s: %s"
+                % (path, error)) from error
+
+
+def _remote_parquet_files(source_file_io, directory):
+    try:
+        statuses = source_file_io.list_status(directory)
+    except LegacyOssDirectoryListingError as error:
+        raise ValueError(
+            "LeRobot URI directory listing is unavailable at %s; use "
+            "Jindo or upgrade PyArrow." % directory) from error
+    paths = []
+    for status in statuses:
+        path = _qualified_status_path(directory, status)
+        if status.type == pafs.FileType.Directory:
+            paths.extend(_remote_parquet_files(source_file_io, path))
+        elif status.type == pafs.FileType.File and path.endswith(".parquet"):
+            paths.append(path)
+    return sorted(paths)
+
+
+def _has_tasks(dataset, info):
+    return int(info.get("total_tasks", 0)) > 0 \
+        and getattr(dataset.meta, "tasks", None) is not None
diff --git a/paimon-python/pypaimon/multimodal/source_utils.py 
b/paimon-python/pypaimon/multimodal/source_utils.py
new file mode 100644
index 0000000000..1e3c58014e
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/source_utils.py
@@ -0,0 +1,67 @@
+# 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 validation for external multimodal sources."""
+
+import os
+from typing import Mapping
+from urllib.parse import urlparse
+
+
+def _source_path_text(value):
+    try:
+        path = os.fspath(value)
+    except TypeError as error:
+        raise ValueError(
+            "paths must contain only filesystem paths or URIs.") from error
+    if isinstance(path, bytes):
+        raise ValueError("paths must contain only filesystem paths or URIs.")
+    return path
+
+
+def _validated_source_options(source_options):
+    if source_options is None:
+        return {}
+    if not isinstance(source_options, Mapping):
+        raise ValueError("source_options must be a mapping.")
+    return dict(source_options)
+
+
+def _validate_source_kerberos(paths, source_options, source_name="HDF5"):
+    source_principal = (
+        source_options.get("security.kerberos.login.principal")
+        or source_options.get("security.principal")
+    )
+    source_keytab = (
+        source_options.get("security.kerberos.login.keytab")
+        or source_options.get("security.keytab")
+    )
+    if not source_principal and not source_keytab:
+        return
+    if bool(source_principal) != bool(source_keytab):
+        raise ValueError(
+            "Source Kerberos principal and keytab must be both set or both "
+            "unset.")
+    if not any(
+            urlparse(_source_path_text(path)).scheme.lower()
+            in ("hdfs", "viewfs") for path in paths):
+        return
+    raise ValueError(
+        "%s sources cannot use an explicit Kerberos keytab in a shared "
+        "process because kinit overwrites process-global credentials. "
+        "Run the load in a process-isolated worker with a pre-acquired "
+        "ticket cache and omit the source principal and keytab options."
+        % source_name)
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
new file mode 100644
index 0000000000..7f1f8d10a8
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -0,0 +1,791 @@
+# 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 builtins
+import json
+import shutil
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+import numpy as np
+import pyarrow as pa
+import pyarrow.fs as pafs
+
+import pypaimon.multimodal as pmm
+from pypaimon.common.options import Options
+from pypaimon.multimodal.hdf5 import _Hdf5SourceFileIO
+from pypaimon.multimodal.lerobot import load_from_lerobot
+from pypaimon.multimodal.lerobot.loader import (
+    _image_bytes,
+    _read_batch,
+    _task_name,
+)
+from pypaimon.multimodal.lerobot.schema import (
+    _schema_from_info,
+    _validate_lerobot_schema,
+)
+from pypaimon.multimodal.lerobot.source import (
+    _LeRobotSource,
+    _RemoteLeRobotDataset,
+    _import_lerobot_dataset,
+    _open_dataset,
+    _remote_source_path,
+    _validate_info_paths,
+)
+
+try:
+    from lerobot.datasets.lerobot_dataset import LeRobotDataset
+except ImportError:
+    LeRobotDataset = None
+
+
+def _replaced_contract(field, old, new):
+    description = field.metadata[b"description"].decode("utf-8")
+    if old not in description:
+        raise AssertionError("%r is missing from %r" % (old, description))
+    return {
+        b"description": description.replace(old, new).encode("utf-8"),
+    }
+
+
+class LeRobotValidationTest(unittest.TestCase):
+
+    def test_dataset_open_never_downloads_videos(self):
+        calls = []
+
+        class Dataset:
+
+            def __init__(self, **kwargs):
+                calls.append(kwargs)
+
+        _open_dataset(Dataset, _LeRobotSource(
+            path="lerobot/example",
+            root=None,
+            repo_id="lerobot/example",
+        ))
+        self.assertFalse(calls[0]["download_videos"])
+
+    def test_dataset_paths_cannot_escape_source(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_paths_"))
+        try:
+            root = temp_dir / "source"
+            root.mkdir()
+            inside = root / "frame.png"
+            inside.write_bytes(b"frame")
+            outside = temp_dir / "secret"
+            outside.write_bytes(b"secret")
+
+            self.assertEqual(
+                b"frame",
+                _image_bytes({"path": str(inside)}, root),
+            )
+            for path in ("../secret", str(outside)):
+                with self.assertRaisesRegex(ValueError, "within the source"):
+                    _image_bytes({"path": path}, root)
+
+            _validate_info_paths({
+                "data_path": "data/chunk-{chunk_index:03d}/file.parquet",
+            })
+            for path in (
+                    "../secret.parquet",
+                    "%2e%2e/secret.parquet",
+                    "%252e%252e/secret.parquet"):
+                with self.assertRaisesRegex(ValueError, "info.data_path"):
+                    _validate_info_paths({"data_path": path})
+        finally:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    def test_remote_image_paths_cannot_escape_source(self):
+        root = "oss://bucket/datasets/robot"
+        self.assertEqual(
+            root + "/images/frame.png",
+            _remote_source_path(root, "images/frame.png", "image path"),
+        )
+        self.assertEqual(
+            root + "/images/frame.png",
+            _remote_source_path(
+                root,
+                root + "/images/frame.png",
+                "image path",
+            ),
+        )
+        for path in (
+                "../private",
+                "%2e%2e/private",
+                "%252e%252e/private",
+                "oss://other/private",
+                "oss://bucket/datasets/robot/../../private"):
+            with self.assertRaisesRegex(ValueError, "within the source"):
+                _remote_source_path(root, path, "image path")
+
+    def test_double_encoded_file_uri_cannot_escape_source(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_uri_"))
+        source_file_io = _Hdf5SourceFileIO(Options({}))
+        try:
+            root = temp_dir / "source"
+            root.mkdir()
+            (root / "frame one").write_bytes(b"frame")
+            (temp_dir / "secret").write_bytes(b"secret")
+            source_path = _remote_source_path(
+                root.as_uri(),
+                "frame%20one",
+                "image path",
+                source_file_io,
+            )
+            with source_file_io.new_input_stream(source_path) as stream:
+                self.assertEqual(b"frame", stream.read())
+            with self.assertRaisesRegex(ValueError, "within the source"):
+                _remote_source_path(
+                    root.as_uri(),
+                    "%252e%252e/secret",
+                    "image path",
+                    source_file_io,
+                )
+        finally:
+            source_file_io.close()
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    def test_hdfs_source_rejects_explicit_keytab_before_resolution(self):
+        with patch(
+                "pypaimon.multimodal.lerobot.source._Hdf5SourceFileIO"
+        ) as source_file_io:
+            with self.assertRaisesRegex(ValueError, "process-isolated"):
+                load_from_lerobot(
+                    Mock(),
+                    "frames",
+                    "hdfs://source-ns/robot",
+                    source_options={
+                        "security.kerberos.login.principal": "source@REALM",
+                        "security.kerberos.login.keytab": "/source.keytab",
+                    },
+                )
+        source_file_io.assert_not_called()
+
+    def test_negative_task_index_is_rejected(self):
+        with self.assertRaisesRegex(ValueError, "task_index -1"):
+            _task_name(["pick", "place"], -1)
+        self.assertEqual("place", _task_name(["pick", "place"], 1))
+
+    def test_optional_dependency_error_is_actionable(self):
+        original_import = builtins.__import__
+
+        def reject_lerobot(name, *args, **kwargs):
+            if name.startswith("lerobot"):
+                raise ImportError("missing for test")
+            return original_import(name, *args, **kwargs)
+
+        with patch("builtins.__import__", side_effect=reject_lerobot):
+            with self.assertRaisesRegex(
+                    ImportError, r"install 'pypaimon\[lerobot\]'"):
+                _import_lerobot_dataset()
+
+    def test_schema_comes_from_metadata_and_rejects_unsupported_types(self):
+        info = {
+            "features": {
+                "scalar": {"dtype": "uint16", "shape": [1]},
+                "vector": {"dtype": "float32", "shape": [3]},
+                "tensor": {"dtype": "float64", "shape": [2, 3]},
+                "image": {"dtype": "image", "shape": [8, 10, 3]},
+            }
+        }
+        schema = _schema_from_info(info, include_task=True)
+
+        self.assertEqual(
+            ["scalar", "vector", "tensor", "image", "task"],
+            schema.names,
+        )
+        self.assertEqual(pa.int32(), schema.field("scalar").type)
+        self.assertEqual(pa.list_(pa.float32(), 3), 
schema.field("vector").type)
+        self.assertEqual(
+            pa.list_(pa.list_(pa.float64(), 3)),
+            schema.field("tensor").type,
+        )
+        self.assertEqual(pa.large_binary(), schema.field("image").type)
+
+        info["features"]["scalar"]["dtype"] = "uint64"
+        with self.assertRaisesRegex(ValueError, "no lossless Paimon integer"):
+            _schema_from_info(info, include_task=False)
+
+        info["features"] = {
+            "camera": {
+                "dtype": "video",
+                "shape": [8, 10, 3],
+            }
+        }
+        with self.assertRaisesRegex(ValueError, "video feature camera.*not 
supported"):
+            _schema_from_info(info, include_task=False)
+
+    def test_existing_schema_preserves_lerobot_feature_contract(self):
+        source = _schema_from_info({
+            "features": {
+                "scalar": {"dtype": "float32", "shape": [1]},
+                "vector": {
+                    "dtype": "float32",
+                    "shape": [3],
+                    "names": ["x", "y", "z"],
+                },
+                "tensor": {"dtype": "float32", "shape": [2, 3]},
+                "image": {"dtype": "image", "shape": [8, 10, 3]},
+            }
+        }, include_task=False)
+
+        replacements = {
+            "shape": pa.field(
+                "tensor",
+                source.field("tensor").type,
+                nullable=False,
+                metadata=_replaced_contract(
+                    source.field("tensor"), "shape=[2,3]", "shape=[5,3]"),
+            ),
+            "dtype": pa.field(
+                "scalar",
+                pa.float64(),
+                nullable=False,
+                metadata=_replaced_contract(
+                    source.field("scalar"),
+                    "dtype=float32",
+                    "dtype=float64",
+                ),
+            ),
+            "names": pa.field(
+                "vector",
+                source.field("vector").type,
+                nullable=False,
+                metadata=_replaced_contract(
+                    source.field("vector"),
+                    'names=["x","y","z"]',
+                    'names=["z","y","x"]',
+                ),
+            ),
+            "array": pa.field(
+                "vector",
+                pa.list_(pa.float32()),
+                nullable=False,
+                metadata=source.field("vector").metadata,
+            ),
+            "bytes": pa.field(
+                "image",
+                pa.binary(),
+                nullable=False,
+                metadata=source.field("image").metadata,
+            ),
+        }
+        for name, replacement in replacements.items():
+            with self.subTest(name=name):
+                target = pa.schema([
+                    replacement if field.name == replacement.name else field
+                    for field in source
+                ])
+                with self.assertRaisesRegex(
+                        ValueError, "cannot be converted"):
+                    _validate_lerobot_schema(source, target, "dataset")
+
+    def test_remote_episode_metadata_projects_stats_columns(self):
+        source = _LeRobotSource(
+            path="oss://bucket/robot",
+            root=None,
+            repo_id="",
+            file_io=Mock(),
+        )
+        info = {
+            "total_frames": 1,
+            "total_episodes": 1,
+            "total_tasks": 0,
+            "data_path": 
"data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
+        }
+        episode_table = pa.table({
+            "episode_index": [0],
+            "dataset_from_index": [0],
+            "dataset_to_index": [1],
+            "data/chunk_index": [0],
+            "data/file_index": [0],
+        })
+        with patch(
+                "pypaimon.multimodal.lerobot.source._remote_parquet_files",
+                
return_value=["oss://bucket/robot/meta/episodes/file.parquet"]):
+            with patch(
+                "pypaimon.multimodal.lerobot.source._read_remote_parquet",
+                return_value=episode_table,
+            ) as read_parquet:
+                _RemoteLeRobotDataset(source, info)
+
+        read_parquet.assert_called_once_with(
+            source.file_io,
+            "oss://bucket/robot/meta/episodes/file.parquet",
+            columns=_RemoteLeRobotDataset._EPISODE_COLUMNS,
+        )
+
+    def test_empty_local_dataset_returns_before_opening_lerobot(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_empty_"))
+        try:
+            source = temp_dir / "source"
+            (source / "meta").mkdir(parents=True)
+            (source / "meta" / "info.json").write_text(json.dumps({
+                "codebase_version": "v3.0",
+                "total_frames": 0,
+                "total_episodes": 0,
+                "total_tasks": 0,
+                "features": {
+                    "index": {"dtype": "int64", "shape": [1]},
+                },
+            }))
+            connection = pmm.connect(options={
+                "warehouse": str(temp_dir / "warehouse"),
+            })
+            with patch(
+                    "pypaimon.multimodal.lerobot.api._import_lerobot_dataset"
+            ) as import_lerobot:
+                self.assertIsNone(connection.load_from_lerobot(
+                    "empty_frames", source))
+            import_lerobot.assert_not_called()
+            table = connection.get_table("empty_frames")
+            self.assertIsNone(
+                table.raw_table.snapshot_manager().get_latest_snapshot())
+        finally:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    def test_empty_fast_path_validates_required_counts(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_counts_"))
+        try:
+            connection = pmm.connect(options={
+                "warehouse": str(temp_dir / "warehouse"),
+            })
+            base_info = {
+                "codebase_version": "v3.0",
+                "total_frames": 0,
+                "total_episodes": 0,
+                "total_tasks": 0,
+                "features": {
+                    "index": {"dtype": "int64", "shape": [1]},
+                },
+            }
+            cases = [
+                ("missing_frames", {}, "total_frames",
+                 "missing required field total_frames"),
+                ("inconsistent_episodes", {"total_episodes": 1}, None,
+                 "must both be zero or both be positive"),
+                ("negative_tasks", {"total_tasks": -1}, None,
+                 "must be a non-negative integer"),
+            ]
+            for name, updates, missing_field, message in cases:
+                with self.subTest(name=name):
+                    info = dict(base_info)
+                    info.update(updates)
+                    if missing_field is not None:
+                        info.pop(missing_field)
+                    source = temp_dir / name
+                    (source / "meta").mkdir(parents=True)
+                    (source / "meta" / "info.json").write_text(
+                        json.dumps(info))
+                    with patch(
+                            "pypaimon.multimodal.lerobot.api."
+                            "_import_lerobot_dataset"
+                    ) as import_lerobot:
+                        with self.assertRaisesRegex(ValueError, message):
+                            connection.load_from_lerobot(name, source)
+                    import_lerobot.assert_not_called()
+        finally:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    def test_source_values_are_safely_converted(self):
+        class Dataset:
+
+            def __init__(self, value):
+                self.value = value
+
+            def read_batch(self, unused_begin, unused_end):
+                return pa.table({"value": [self.value]})
+
+        cases = [
+            ({"dtype": "int32", "shape": [1]}, 1.5, "safely converted"),
+            ({"dtype": "float32", "shape": [1]}, 1e100, "float32 range"),
+            ({"dtype": "uint8", "shape": [1]}, -1, "uint8 range"),
+            ({"dtype": "uint8", "shape": [2]}, [0, 256], "uint8 range"),
+            ({"dtype": "uint16", "shape": [1]}, 65536, "uint16 range"),
+            ({"dtype": "uint32", "shape": [1]}, -1, "uint32 range"),
+            ({"dtype": "float16", "shape": [1]}, 70000, "float16 range"),
+            ({"dtype": "uint8", "shape": [1]}, "256", "non-numeric"),
+            ({"dtype": "float32", "shape": [1]}, "1e100", "non-numeric"),
+            ({"dtype": "bool", "shape": [1]}, 2, "non-boolean"),
+            ({"dtype": "string", "shape": [1]}, 123, "non-string"),
+        ]
+        for feature, value, message in cases:
+            with self.subTest(feature=feature, value=value):
+                info = {"features": {"value": feature}}
+                schema = _schema_from_info(info, include_task=False)
+                with self.assertRaisesRegex(ValueError, message):
+                    _read_batch(Dataset(value), info, 0, 1, schema)
+
+        boundary_cases = [
+            ({"dtype": "uint8", "shape": [1]}, 255),
+            ({"dtype": "uint16", "shape": [1]}, 65535),
+            ({"dtype": "uint32", "shape": [1]}, 4294967295),
+            ({"dtype": "float16", "shape": [1]}, 65504),
+            ({"dtype": "bool", "shape": [1]}, True),
+            ({"dtype": "string", "shape": [1]}, "pick"),
+        ]
+        for feature, value in boundary_cases:
+            with self.subTest(feature=feature, value=value):
+                info = {"features": {"value": feature}}
+                schema = _schema_from_info(info, include_task=False)
+                result = _read_batch(Dataset(value), info, 0, 1, schema)
+                self.assertEqual(value, result.column("value")[0].as_py())
+
+    def test_local_v2_is_rejected_before_opening(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_v2_"))
+        try:
+            info_dir = temp_dir / "meta"
+            info_dir.mkdir()
+            (info_dir / "info.json").write_text(json.dumps({
+                "codebase_version": "v2.1",
+                "features": {"index": {"dtype": "int64", "shape": [1]}},
+            }))
+            connection = pmm.connect(options={
+                "warehouse": str(temp_dir / "warehouse"),
+            })
+            with self.assertRaisesRegex(ValueError, "supports LeRobot Dataset 
v3 only"):
+                connection.load_from_lerobot("frames", temp_dir)
+        finally:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+    def test_local_video_is_rejected_before_opening(self):
+        temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_video_"))
+        try:
+            info_dir = temp_dir / "meta"
+            info_dir.mkdir()
+            (info_dir / "info.json").write_text(json.dumps({
+                "codebase_version": "v3.0",
+                "features": {
+                    "camera": {"dtype": "video", "shape": [8, 10, 3]},
+                },
+            }))
+            connection = pmm.connect(options={
+                "warehouse": str(temp_dir / "warehouse"),
+            })
+            with self.assertRaisesRegex(
+                    ValueError, "video feature camera.*not supported"):
+                connection.load_from_lerobot("frames", temp_dir)
+        finally:
+            shutil.rmtree(temp_dir, ignore_errors=True)
+
+
+class _RemoteLeRobotFileIO:
+
+    def __init__(self, local_root, remote_root):
+        self.local_root = Path(local_root)
+        self.remote_root = remote_root.rstrip("/")
+        self.opened_paths = []
+        self.close_count = 0
+
+    def _local_path(self, remote_path):
+        prefix = self.remote_root + "/"
+        if remote_path == self.remote_root:
+            return self.local_root
+        if not remote_path.startswith(prefix):
+            raise FileNotFoundError(remote_path)
+        return self.local_root / remote_path[len(prefix):]
+
+    def _status(self, local_path):
+        relative = local_path.relative_to(self.local_root).as_posix()
+        remote_path = self.remote_root
+        if relative != ".":
+            remote_path += "/" + relative
+        native_path = remote_path.split("://", 1)[1]
+        file_type = pafs.FileType.Directory if local_path.is_dir() \
+            else pafs.FileType.File
+        return pafs.FileInfo(native_path, file_type)
+
+    def get_file_status(self, remote_path):
+        local_path = self._local_path(remote_path)
+        if not local_path.exists():
+            raise FileNotFoundError(remote_path)
+        return self._status(local_path)
+
+    def list_status(self, remote_path):
+        return [self._status(path) for path in sorted(
+            self._local_path(remote_path).iterdir())]
+
+    def new_input_stream(self, remote_path):
+        self.opened_paths.append(remote_path)
+        return self._local_path(remote_path).open("rb")
+
+    def close(self):
+        self.close_count += 1
+
+
[email protected](
+    sys.version_info >= (3, 10) and LeRobotDataset is not None,
+    "LeRobot 0.4.x requires Python 3.10+ and the lerobot extra",
+)
+class LeRobotImportTest(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.source_dir = 
Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_source_"))
+        cls.image_source = cls.source_dir / "images"
+        cls._create_image_dataset(cls.image_source)
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.source_dir, ignore_errors=True)
+
+    def setUp(self):
+        self.temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_test_"))
+        self.connection = pmm.connect(options={
+            "warehouse": str(self.temp_dir / "warehouse"),
+        })
+
+    def tearDown(self):
+        shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+    @staticmethod
+    def _create_image_dataset(root):
+        dataset = LeRobotDataset.create(
+            repo_id="pypaimon/local-image-test",
+            root=root,
+            fps=10,
+            use_videos=False,
+            image_writer_processes=0,
+            image_writer_threads=0,
+            features={
+                "observation.state": {
+                    "dtype": "float32",
+                    "shape": (3,),
+                    "names": ["x", "y", "z"],
+                },
+                "observation.matrix": {
+                    "dtype": "float32",
+                    "shape": (2, 2),
+                    "names": None,
+                },
+                "action": {
+                    "dtype": "float32",
+                    "shape": (2,),
+                    "names": ["x", "y"],
+                },
+                "reward": {
+                    "dtype": "float32",
+                    "shape": (1,),
+                    "names": None,
+                },
+                "observation.image": {
+                    "dtype": "image",
+                    "shape": (8, 10, 3),
+                    "names": ["height", "width", "channels"],
+                },
+            },
+        )
+        for episode_index, length in enumerate((2, 3)):
+            for frame_index in range(length):
+                value = episode_index * 80 + frame_index * 10
+                dataset.add_frame({
+                    "observation.state": np.array(
+                        [episode_index, frame_index, episode_index + 
frame_index],
+                        dtype=np.float32,
+                    ),
+                    "observation.matrix": np.array(
+                        [[episode_index, frame_index], [frame_index, 
episode_index]],
+                        dtype=np.float32,
+                    ),
+                    "action": np.array(
+                        [frame_index, -frame_index], dtype=np.float32),
+                    "reward": np.array([float(frame_index == length - 1)],
+                                       dtype=np.float32),
+                    "observation.image": np.full(
+                        (8, 10, 3), value, dtype=np.uint8),
+                    "task": "pick" if episode_index == 0 else "place",
+                })
+            dataset.save_episode()
+        dataset.finalize()
+
+    def test_import_infers_schema_preserves_episodes_and_appends(self):
+        snapshot_id = self.connection.load_from_lerobot(
+            "robot_data", self.image_source, batch_size=2)
+
+        self.assertEqual(1, snapshot_id)
+
+        table = self.connection.get_table("robot_data")
+        schema = table.raw_table.fields
+        types = {field.name: str(field.type) for field in schema}
+        self.assertEqual("VECTOR<FLOAT, 3> NOT NULL", 
types["observation.state"])
+        self.assertEqual(
+            "ARRAY<VECTOR<FLOAT, 2>> NOT NULL",
+            types["observation.matrix"],
+        )
+        self.assertEqual("VECTOR<FLOAT, 2> NOT NULL", types["action"])
+        self.assertEqual("FLOAT NOT NULL", types["timestamp"])
+        self.assertEqual("BIGINT NOT NULL", types["episode_index"])
+        self.assertEqual("BLOB NOT NULL", types["observation.image"])
+
+        rows = table.scan().select([
+            "episode_index",
+            "frame_index",
+            "timestamp",
+            "index",
+            "task_index",
+            "task",
+            "observation.state",
+            "observation.matrix",
+            "action",
+            "reward",
+        ]).to_arrow().sort_by("index").to_pylist()
+        self.assertEqual([0, 0, 1, 1, 1], [row["episode_index"] for row in 
rows])
+        self.assertEqual([0, 1, 0, 1, 2], [row["frame_index"] for row in rows])
+        self.assertEqual([0, 1, 2, 3, 4], [row["index"] for row in rows])
+        self.assertEqual(["pick", "pick", "place", "place", "place"],
+                         [row["task"] for row in rows])
+        self.assertEqual([1.0, -1.0], rows[1]["action"])
+        self.assertEqual([[1.0, 2.0], [2.0, 1.0]],
+                         rows[4]["observation.matrix"])
+        self.assertAlmostEqual(0.2, rows[4]["timestamp"], places=6)
+        self.assertEqual(1.0, rows[4]["reward"])
+        self.assertEqual(
+            snapshot_id,
+            table.raw_table.snapshot_manager().get_latest_snapshot().id,
+        )
+
+        scalar, blobs = table.scan().select([
+            "index", "observation.image"]
+        ).read_blobs()
+        imported = dict(zip(
+            scalar.column("index").to_pylist(), blobs["observation.image"]))
+        source = LeRobotDataset(
+            repo_id="pypaimon/local-image-test",
+            root=self.image_source,
+        ).hf_dataset.with_format("arrow")[:]
+        expected = source.column("observation.image").to_pylist()
+        self.assertEqual(
+            [value["bytes"] for value in expected],
+            [imported[index] for index in range(5)],
+        )
+
+        appended_snapshot_id = self.connection.load_from_lerobot(
+            "robot_data", self.image_source, batch_size=4)
+        self.assertEqual(2, appended_snapshot_id)
+        self.assertEqual(10, table.scan().to_arrow().num_rows)
+
+    def test_oss_source_streams_parquet_and_preserves_episodes(self):
+        source = "oss://source-bucket/robot-images"
+        source_file_io = _RemoteLeRobotFileIO(self.image_source, source)
+
+        with patch(
+                "pypaimon.multimodal.lerobot.source._Hdf5SourceFileIO",
+                return_value=source_file_io):
+            snapshot_id = self.connection.load_from_lerobot(
+                "oss_images",
+                source,
+                batch_size=2,
+            )
+
+        self.assertEqual(1, snapshot_id)
+        table = self.connection.get_table("oss_images")
+        rows = table.scan().select([
+            "episode_index", "frame_index", "index", "task"
+        ]).to_arrow().sort_by("index").to_pylist()
+        self.assertEqual([0, 0, 1, 1, 1], [
+            row["episode_index"] for row in rows
+        ])
+        self.assertEqual([0, 1, 0, 1, 2], [
+            row["frame_index"] for row in rows
+        ])
+        self.assertEqual(
+            ["pick", "pick", "place", "place", "place"],
+            [row["task"] for row in rows],
+        )
+        self.assertFalse(any(
+            path.endswith("meta/stats.json")
+            for path in source_file_io.opened_paths
+        ))
+        self.assertEqual(1, len([
+            path for path in source_file_io.opened_paths
+            if "/data/" in path and path.endswith(".parquet")
+        ]))
+
+    def test_existing_incompatible_schema_fails_without_snapshot(self):
+        info = json.loads((self.image_source / "meta" / 
"info.json").read_text())
+        schema = _schema_from_info(info, include_task=True)
+        incompatible_fields = {
+            "shape": pa.field(
+                "observation.matrix",
+                schema.field("observation.matrix").type,
+                nullable=False,
+                metadata=_replaced_contract(
+                    schema.field("observation.matrix"),
+                    "shape=[2,2]",
+                    "shape=[5,2]",
+                ),
+            ),
+            "dtype": pa.field(
+                "action",
+                pa.list_(pa.float64(), 2),
+                nullable=False,
+                metadata=_replaced_contract(
+                    schema.field("action"),
+                    "dtype=float32",
+                    "dtype=float64",
+                ),
+            ),
+            "names": pa.field(
+                "action",
+                schema.field("action").type,
+                nullable=False,
+                metadata=_replaced_contract(
+                    schema.field("action"),
+                    'names=["x","y"]',
+                    'names=["y","x"]',
+                ),
+            ),
+            "array": pa.field(
+                "action",
+                pa.list_(pa.float32()),
+                nullable=False,
+                metadata=schema.field("action").metadata,
+            ),
+            "bytes": pa.field(
+                "observation.image",
+                pa.binary(),
+                nullable=False,
+                metadata=schema.field("observation.image").metadata,
+            ),
+        }
+        for name, replacement in incompatible_fields.items():
+            with self.subTest(name=name):
+                table_name = "incompatible_%s" % name
+                table = self.connection.create_table(
+                    table_name,
+                    schema=pa.schema([
+                        replacement if field.name == replacement.name
+                        else field
+                        for field in schema
+                    ]),
+                    options={
+                        "file.format": "parquet",
+                        "vector.file.format": "parquet",
+                    },
+                )
+
+                with self.assertRaisesRegex(
+                        ValueError, "cannot be converted"):
+                    self.connection.load_from_lerobot(
+                        table_name, self.image_source)
+                self.assertIsNone(
+                    table.raw_table.snapshot_manager().get_latest_snapshot())
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 9111730fb7..8f0e57edc2 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -237,6 +237,14 @@ setup(
             # HDF5 loading is explicitly guarded and documented as Python 3.8+.
             'h5py>=3,<4; python_version>="3.8"',
         ],
+        'lerobot': [
+            # datasets 4.1+ may select PyArrow 21+, while PyPaimon currently
+            # supports PyArrow <20. Pandas 2.2.2+ supports NumPy 2.x selected
+            # by LeRobot's media dependencies.
+            'datasets>=4,<4.1; python_version>="3.10"',
+            'pandas>=2.2.2,<3; python_version>="3.10"',
+            'lerobot>=0.4.4,<0.5; python_version>="3.10"',
+        ],
         'ray': [
             'ray>=2.10,<3; python_version>="3.8"',
         ],

Reply via email to