#!/usr/bin/env python # Copyright 2025 The HuggingFace Inc. team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from pathlib import Path from unittest.mock import MagicMock import pytest from lerobot.common.train_utils import ( get_step_checkpoint_dir, get_step_identifier, load_training_metadata, push_checkpoint_to_hub, save_training_metadata, save_training_state, should_save_checkpoint, update_last_checkpoint, ) from lerobot.configs.default import DatasetConfig from lerobot.configs.train import TrainPipelineConfig from lerobot.utils.constants import ( CHECKPOINTS_DIR, LAST_CHECKPOINT_LINK, OPTIMIZER_PARAM_GROUPS, OPTIMIZER_STATE, RNG_STATE, SCHEDULER_STATE, TRAINING_STATE_DIR, TRAINING_STEP, ) def test_get_step_identifier(): assert get_step_identifier(5, 1000) == "000005" assert get_step_identifier(123, 100_000) == "000123" assert get_step_identifier(456789, 1_000_000) == "0456789" def test_should_save_checkpoint(): # Periodic checkpoints land on multiples of save_freq. assert should_save_checkpoint(10, save_freq=10, total_steps=100) is True assert should_save_checkpoint(5, save_freq=10, total_steps=100) is False # The final step always saves, even when it is not a multiple of save_freq. assert should_save_checkpoint(100, save_freq=30, total_steps=100) is True # save_freq <= 0 disables periodic saving without raising ZeroDivisionError. assert should_save_checkpoint(1, save_freq=0, total_steps=100) is False assert should_save_checkpoint(100, save_freq=0, total_steps=100) is True assert should_save_checkpoint(1, save_freq=-1, total_steps=100) is False def test_get_step_checkpoint_dir(): output_dir = Path("/checkpoints") step_dir = get_step_checkpoint_dir(output_dir, 1000, 5) assert step_dir == output_dir / CHECKPOINTS_DIR / "000005" def make_cfg(batch_size: int = 32) -> TrainPipelineConfig: cfg = TrainPipelineConfig(dataset=DatasetConfig(repo_id="lerobot/dummy"), batch_size=batch_size) cfg.parallelism.resolve(1) return cfg def test_save_training_metadata_writes_the_step_file(tmp_path): save_training_metadata(5000, tmp_path, make_cfg()) assert (tmp_path / TRAINING_STEP).is_file() def test_save_training_state_records_topology(tmp_path, optimizer, scheduler): save_training_state(tmp_path, 10, make_cfg(batch_size=32), optimizer, scheduler) metadata = load_training_metadata(tmp_path / TRAINING_STATE_DIR) assert metadata["step"] == 10 assert metadata["dp_world_size"] == 1 assert metadata["batch_size"] == 32 def test_update_last_checkpoint(tmp_path): checkpoint = tmp_path / "0005" checkpoint.mkdir() update_last_checkpoint(checkpoint) last_checkpoint = tmp_path / LAST_CHECKPOINT_LINK assert last_checkpoint.is_symlink() assert last_checkpoint.resolve() == checkpoint # save_checkpoint round-trips (all formats, real policies) live in # tests/common/test_checkpoint_save_resume.py. def test_save_training_state_layout(tmp_path, optimizer, scheduler): save_training_state(tmp_path, 10, make_cfg(), optimizer, scheduler) assert (tmp_path / TRAINING_STATE_DIR).is_dir() assert (tmp_path / TRAINING_STATE_DIR / TRAINING_STEP).is_file() assert (tmp_path / TRAINING_STATE_DIR / RNG_STATE).is_file() assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_STATE).is_file() assert (tmp_path / TRAINING_STATE_DIR / OPTIMIZER_PARAM_GROUPS).is_file() assert (tmp_path / TRAINING_STATE_DIR / SCHEDULER_STATE).is_file() # The two-phase resume (resume_before_prepare / resume_after_prepare) is covered in # tests/common/test_checkpoint_save_resume.py with real policies and optimizer state. def test_push_checkpoint_to_hub_creates_repo_and_uploads(tmp_path, monkeypatch): ckpt = tmp_path / "010000" (ckpt / "pretrained_model").mkdir(parents=True) api = MagicMock() monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api) push_checkpoint_to_hub(ckpt, "user/run", private=True) api.create_repo.assert_called_once() assert api.create_repo.call_args.kwargs["private"] is True assert api.create_repo.call_args.kwargs["repo_type"] == "model" api.upload_folder.assert_called_once() kwargs = api.upload_folder.call_args.kwargs assert kwargs["repo_id"] == "user/run" assert kwargs["repo_type"] == "model" assert kwargs["path_in_repo"] == "checkpoints/010000" assert kwargs["folder_path"] == str(ckpt) assert kwargs["commit_message"] == "checkpoint 010000" # A tag named after the checkpoint step is created so the checkpoint can be # recovered with --policy.pretrained_revision instead of a commit sha. api.create_tag.assert_called_once() tag_kwargs = api.create_tag.call_args.kwargs assert tag_kwargs["tag"] == "010000" assert tag_kwargs["revision"] == api.upload_folder.return_value.oid assert tag_kwargs["repo_type"] == "model" assert tag_kwargs["exist_ok"] is True def test_push_checkpoint_to_hub_defaults_to_hub_default_visibility(tmp_path, monkeypatch): ckpt = tmp_path / "010000" (ckpt / "pretrained_model").mkdir(parents=True) api = MagicMock() monkeypatch.setattr("lerobot.common.train_utils.HfApi", lambda *a, **k: api) push_checkpoint_to_hub(ckpt, "user/run") api.create_repo.assert_called_once() assert api.create_repo.call_args.kwargs["private"] is None def test_resolve_resume_checkpoint_downloads_latest_and_links(tmp_path, monkeypatch): from lerobot.common import train_utils out = tmp_path / "run" def fake_snapshot_download(repo_id, repo_type, allow_patterns, local_dir): # Mimic the Hub layout the real download materializes locally. assert allow_patterns == "checkpoints/020000/*" (Path(local_dir) / "checkpoints" / "020000" / "pretrained_model").mkdir(parents=True) return local_dir monkeypatch.setattr("lerobot.common.train_utils.snapshot_download", fake_snapshot_download) monkeypatch.setattr( "lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: "checkpoints/020000" ) checkpoint_dir = train_utils.resolve_resume_checkpoint("u/run", out) assert checkpoint_dir == out / CHECKPOINTS_DIR / "020000" last = out / CHECKPOINTS_DIR / LAST_CHECKPOINT_LINK assert last.is_symlink() # `last` points at the downloaded step dir. assert (last.parent / last.readlink()).resolve() == checkpoint_dir.resolve() def test_resolve_resume_checkpoint_raises_without_checkpoints(tmp_path, monkeypatch): from lerobot.common import train_utils monkeypatch.setattr("lerobot.common.train_utils.find_latest_hub_checkpoint", lambda repo_id: None) with pytest.raises(FileNotFoundError, match="No checkpoint"): train_utils.resolve_resume_checkpoint("u/run", tmp_path / "run")