Skip to content

Commit 3c940e2

Browse files
Asier Arranzclaude
andcommitted
Make convert_v3_to_v2 non-destructive by default
The script replaced the source v3.0 dataset in place and deleted existing _v3.0/_v2.1 sibling dirs without warning, silently destroying data. Write the converted dataset to --output (default: sibling _v2.1 dir) and gate in-place replacement behind an explicit --in-place flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1a1837f commit 3c940e2

4 files changed

Lines changed: 236 additions & 12 deletions

File tree

examples/SO100/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ Visualize it with this [link](https://huggingface.co/spaces/lerobot/visualize_da
1717
uv run --project scripts/lerobot_conversion \
1818
python scripts/lerobot_conversion/convert_v3_to_v2.py \
1919
--repo-id izuluaga/finish_sandwich \
20-
--root examples/SO100/finish_sandwich_lerobot
20+
--root examples/SO100/finish_sandwich_lerobot \
21+
--in-place
2122
```
2223

24+
> `--in-place` replaces the downloaded v3.0 dataset with the converted v2.1 one, so
25+
> the paths below stay the same. The original v3.0 dataset is kept in a sibling
26+
> `finish_sandwich_backup_v3.0` directory.
27+
2328
Then copy the `modality.json` file into the dataset's `meta/` directory:
2429
```bash
2530
cp examples/SO100/modality.json examples/SO100/finish_sandwich_lerobot/izuluaga/finish_sandwich/meta/modality.json

scripts/lerobot_conversion/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ Inside the uv environment, run:
2121
python convert_v3_to_v2.py --repo-id BobShan/double_folding_towel_v3.0
2222
```
2323

24+
The converted v2.1 dataset is written next to the source dataset with a `_v2.1`
25+
suffix (or to `--output` if given); the source dataset is left untouched. Pass
26+
`--in-place` to replace the source instead; the original v3.0 dataset is then
27+
kept in a sibling directory with a `_backup_v3.0` suffix.
28+
2429
> **Note:** You may need to install lerobot with `GIT_LFS_SKIP_SMUDGE=1`:
2530
>
2631
> ```bash

scripts/lerobot_conversion/convert_v3_to_v2.py

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
Usage examples
1919
--------------
2020
21-
- Convert a lerobot v3.0 dataset that already exists locally (The v3.0 path will be overwritten by the v2.1 path and a folder with the suffix _v30 will be created containing the original v3.0 dataset)
21+
- Convert a lerobot v3.0 dataset that already exists locally. The converted v2.1
22+
dataset is written to `--output` (default: a sibling directory with a `_v2.1`
23+
suffix) and the source dataset is left untouched. Pass `--in-place` to replace
24+
the source instead; the original v3.0 dataset is then kept in a sibling
25+
directory with a `_backup_v3.0` suffix.
2226
2327
- This needs lerobot version atleast after commit f55c6e89f.
2428
@@ -483,13 +487,65 @@ def copy_ancillary_directories(root: Path, new_root: Path) -> None:
483487
shutil.copytree(source, new_root / subdir, dirs_exist_ok=True)
484488

485489

490+
def resolve_output_roots(
491+
root: Path,
492+
output: str | Path | None,
493+
in_place: bool,
494+
) -> tuple[Path, Path | None]:
495+
"""Resolve and validate the destination directories for a conversion.
496+
497+
Returns the directory the converted v2.1 dataset is written to and, when
498+
``in_place`` is requested, the directory the original v3.0 dataset is kept in.
499+
Raises instead of silently overwriting existing data.
500+
"""
501+
502+
if output is not None and in_place:
503+
raise ValueError("--output and --in-place are mutually exclusive.")
504+
505+
new_root = root.parent / f"{root.name}_{V21}" if output is None else Path(output)
506+
507+
root_resolved = root.resolve()
508+
new_root_resolved = new_root.resolve()
509+
if new_root_resolved == root_resolved or root_resolved in new_root_resolved.parents:
510+
raise ValueError(
511+
f"Output directory '{new_root}' would overwrite the source dataset at '{root}'. "
512+
"Pass a different --output, or use --in-place to replace the source explicitly."
513+
)
514+
if new_root.exists():
515+
raise FileExistsError(
516+
f"Output directory already exists: {new_root}. Remove it or pass a different --output."
517+
)
518+
519+
backup_root = None
520+
if in_place:
521+
backup_root = root.parent / f"{root.name}_backup_{V30}"
522+
if backup_root.exists():
523+
raise FileExistsError(
524+
f"Backup directory already exists: {backup_root}. "
525+
"Remove it before converting in place again."
526+
)
527+
528+
return new_root, backup_root
529+
530+
486531
def convert_dataset(
487532
repo_id: str,
488533
root: str | Path | None = None,
534+
output: str | Path | None = None,
535+
in_place: bool = False,
489536
force_conversion: bool = False,
490537
) -> None:
491538
root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) / repo_id
492539

540+
new_root, backup_root = resolve_output_roots(root, output, in_place)
541+
if in_place:
542+
logging.warning(
543+
"--in-place enabled: %s will be replaced by the converted v2.1 dataset; "
544+
"the original v3.0 dataset will be kept at %s",
545+
root,
546+
backup_root,
547+
)
548+
493549
if root.exists() and force_conversion:
494550
logging.info("--force-conversion enabled: removing existing snapshot at %s", root)
495551
shutil.rmtree(root)
@@ -506,14 +562,6 @@ def convert_dataset(
506562
video_keys = [key for key, ft in info["features"].items() if ft.get("dtype") == "video"]
507563
chunks_size = info.get("chunks_size", DEFAULT_CHUNK_SIZE)
508564

509-
backup_root = root.parent / f"{root.name}_{V30}"
510-
new_root = root.parent / f"{root.name}_{V21}"
511-
512-
if backup_root.is_dir():
513-
shutil.rmtree(backup_root)
514-
if new_root.is_dir():
515-
shutil.rmtree(new_root)
516-
517565
new_root.mkdir(parents=True, exist_ok=True)
518566

519567
convert_info(root, new_root, episode_records, video_keys)
@@ -524,8 +572,20 @@ def convert_dataset(
524572
convert_episodes_metadata(new_root, episode_records)
525573
copy_ancillary_directories(root, new_root)
526574

527-
shutil.move(str(root), str(backup_root))
528-
shutil.move(str(new_root), str(root))
575+
if in_place:
576+
shutil.move(str(root), str(backup_root))
577+
shutil.move(str(new_root), str(root))
578+
logging.info(
579+
"Replaced %s with the converted v2.1 dataset; the original v3.0 dataset is kept at %s",
580+
root,
581+
backup_root,
582+
)
583+
else:
584+
logging.info(
585+
"Converted v2.1 dataset written to %s; the source dataset at %s was left untouched",
586+
new_root,
587+
root,
588+
)
529589

530590

531591
def parse_args() -> argparse.Namespace:
@@ -542,6 +602,21 @@ def parse_args() -> argparse.Namespace:
542602
default=None,
543603
help="Local directory under which the dataset should be stored.",
544604
)
605+
parser.add_argument(
606+
"--output",
607+
type=str,
608+
default=None,
609+
help="Directory the converted v2.1 dataset is written to. Defaults to a sibling "
610+
"of the source dataset with a `_v2.1` suffix. The source dataset is never "
611+
"modified unless --in-place is passed.",
612+
)
613+
parser.add_argument(
614+
"--in-place",
615+
action="store_true",
616+
help="Replace the source dataset with the converted v2.1 dataset. The original "
617+
"v3.0 dataset is kept in a sibling directory with a `_backup_v3.0` suffix. "
618+
"Mutually exclusive with --output.",
619+
)
545620
parser.add_argument(
546621
"--force-conversion",
547622
action="store_true",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""Tests for the non-destructive output handling of ``convert_v3_to_v2.py``."""
17+
18+
from __future__ import annotations
19+
20+
from pathlib import Path
21+
import sys
22+
import types
23+
24+
import pytest
25+
26+
27+
pytest.importorskip("pyarrow")
28+
29+
30+
def _install_lerobot_stubs() -> list[str]:
31+
"""Install just enough fake ``lerobot`` modules to import the conversion script.
32+
33+
The conversion script lives in its own subproject with a real ``lerobot``
34+
dependency; the destination-resolution logic under test does not need it.
35+
"""
36+
37+
stub_attrs: dict[str, dict[str, object]] = {
38+
"lerobot": {},
39+
"lerobot.datasets": {},
40+
"lerobot.datasets.utils": {
41+
"DEFAULT_CHUNK_SIZE": 1000,
42+
"DEFAULT_DATA_PATH": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
43+
"DEFAULT_VIDEO_PATH": (
44+
"videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4"
45+
),
46+
"EPISODES_DIR": "meta/episodes",
47+
"LEGACY_EPISODES_PATH": "meta/episodes.jsonl",
48+
"LEGACY_EPISODES_STATS_PATH": "meta/episodes_stats.jsonl",
49+
"LEGACY_TASKS_PATH": "meta/tasks.jsonl",
50+
"load_info": lambda *args, **kwargs: None,
51+
"load_tasks": lambda *args, **kwargs: None,
52+
"serialize_dict": lambda *args, **kwargs: None,
53+
"unflatten_dict": lambda *args, **kwargs: None,
54+
"write_info": lambda *args, **kwargs: None,
55+
},
56+
"lerobot.utils": {},
57+
"lerobot.utils.constants": {"HF_LEROBOT_HOME": Path("unused")},
58+
"lerobot.utils.utils": {"init_logging": lambda *args, **kwargs: None},
59+
}
60+
61+
installed: list[str] = []
62+
for name, attrs in stub_attrs.items():
63+
if name in sys.modules:
64+
continue
65+
module = types.ModuleType(name)
66+
for key, value in attrs.items():
67+
setattr(module, key, value)
68+
sys.modules[name] = module
69+
installed.append(name)
70+
return installed
71+
72+
73+
_stub_names = _install_lerobot_stubs()
74+
try:
75+
from scripts.lerobot_conversion.convert_v3_to_v2 import resolve_output_roots
76+
finally:
77+
for _name in _stub_names:
78+
sys.modules.pop(_name, None)
79+
80+
81+
def test_default_output_is_sibling_with_v21_suffix(tmp_path: Path) -> None:
82+
root = tmp_path / "dataset"
83+
root.mkdir()
84+
85+
new_root, backup_root = resolve_output_roots(root, None, in_place=False)
86+
87+
assert new_root == tmp_path / "dataset_v2.1"
88+
assert backup_root is None
89+
90+
91+
def test_refuses_output_equal_to_source(tmp_path: Path) -> None:
92+
root = tmp_path / "dataset"
93+
root.mkdir()
94+
95+
with pytest.raises(ValueError, match="overwrite the source"):
96+
resolve_output_roots(root, root, in_place=False)
97+
98+
99+
def test_refuses_output_inside_source(tmp_path: Path) -> None:
100+
root = tmp_path / "dataset"
101+
root.mkdir()
102+
103+
with pytest.raises(ValueError, match="overwrite the source"):
104+
resolve_output_roots(root, root / "v2", in_place=False)
105+
106+
107+
def test_refuses_existing_output_directory(tmp_path: Path) -> None:
108+
root = tmp_path / "dataset"
109+
root.mkdir()
110+
(tmp_path / "dataset_v2.1").mkdir()
111+
112+
with pytest.raises(FileExistsError, match="Output directory already exists"):
113+
resolve_output_roots(root, None, in_place=False)
114+
115+
116+
def test_in_place_returns_backup_root(tmp_path: Path) -> None:
117+
root = tmp_path / "dataset"
118+
root.mkdir()
119+
120+
new_root, backup_root = resolve_output_roots(root, None, in_place=True)
121+
122+
assert new_root == tmp_path / "dataset_v2.1"
123+
assert backup_root == tmp_path / "dataset_backup_v3.0"
124+
125+
126+
def test_in_place_refuses_existing_backup(tmp_path: Path) -> None:
127+
root = tmp_path / "dataset"
128+
root.mkdir()
129+
(tmp_path / "dataset_backup_v3.0").mkdir()
130+
131+
with pytest.raises(FileExistsError, match="Backup directory already exists"):
132+
resolve_output_roots(root, None, in_place=True)
133+
134+
135+
def test_output_and_in_place_are_mutually_exclusive(tmp_path: Path) -> None:
136+
root = tmp_path / "dataset"
137+
138+
with pytest.raises(ValueError, match="mutually exclusive"):
139+
resolve_output_roots(root, tmp_path / "out", in_place=True)

0 commit comments

Comments
 (0)