Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions frontend/src/pages/Upload.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { expect, test, vi } from "vitest";

import Upload from "./Upload";

const { fetchWithHeaders, toast } = vi.hoisted(() => ({
fetchWithHeaders: vi.fn(),
toast: vi.fn(),
}));

vi.mock("@/contexts/ApiContext", () => ({
useApi: () => ({ baseUrl: "http://api", fetchWithHeaders }),
}));
vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast }) }));

test.each([false, true])("opens the uploaded Hub dataset (private: %s)", async (isPrivate) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const datasetInfo = { dataset_repo_id: "local/sushi2", single_task: "pick", num_episodes: 2 };
let completeUpload!: (response: Response) => void;
const pendingUpload = new Promise<Response>((resolve) => {
completeUpload = resolve;
});
fetchWithHeaders.mockReset().mockImplementation(async (url: string) => {
if (url.endsWith("/dataset-info")) {
return Response.json({ ...datasetInfo, success: true });
}
if (url.endsWith("/upload-dataset")) {
return pendingUpload;
}
throw new Error(`Unexpected request: ${url}`);
});
const open = vi.spyOn(window, "open").mockReturnValue(null);
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const button = (text: string) =>
[...host.querySelectorAll("button")].find((item) => item.textContent?.trim() === text);

try {
await act(async () => {
root.render(
<MemoryRouter initialEntries={[{ pathname: "/upload", state: { datasetInfo } }]}>
<Upload />
</MemoryRouter>,
);
});
if (isPrivate) {
await act(async () => host.querySelector<HTMLButtonElement>("#private")?.click());
}
const upload = button("Upload to HuggingFace Hub");
expect(upload).toBeDefined();
await act(async () => upload?.click());
const [, options] = fetchWithHeaders.mock.calls.find(([url]) => url.endsWith("/upload-dataset"))!;
expect(JSON.parse(options.body)).toMatchObject({ dataset_repo_id: "local/sushi2", private: isPrivate });
await act(async () => {
host.querySelector<HTMLButtonElement>("#private")?.click();
completeUpload(Response.json({ success: true, hub_repo_id: "alice/sushi2" }));
});

const view = button("View on HuggingFace Hub");
expect(view).toBeDefined();
await act(async () => view?.click());
const target = new URL(String(open.mock.calls[0]?.[0]));
expect(target.pathname).toBe(isPrivate ? "/login" : "/spaces/lerobot/visualize_dataset");
const viewer = isPrivate ? new URL(target.searchParams.get("next")!, target.origin) : target;
expect(viewer.searchParams.get("path")).toBe("/alice/sushi2");
} finally {
act(() => root.unmount());
host.remove();
open.mockRestore();
}
});
33 changes: 14 additions & 19 deletions frontend/src/pages/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ const Upload = () => {

const [tagsInput, setTagsInput] = useState(uploadConfig.tags.join(", "));
const [isUploading, setIsUploading] = useState(false);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [uploadedDataset, setUploadedDataset] = useState<{ repoId: string; private: boolean } | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const uploadSuccess = uploadedDataset !== null;

// Load actual dataset information from backend
React.useEffect(() => {
Expand Down Expand Up @@ -122,12 +123,12 @@ const Upload = () => {
loadDatasetInfo();
}, [initialDatasetInfo, navigate, toast]);

const openInHubViewer = (repoId: string) => {
const openInHubViewer = (repoId: string, requiresLogin = true) => {
const spacePath = `/spaces/lerobot/visualize_dataset?path=${encodeURIComponent(`/${repoId}`)}`;
// The user owns/manages the dataset (it appears under their hub
// listing), so login-redirect always works whether public or
// private. Avoids passing `private` through navigation state.
const target = `https://huggingface.co/login?next=${encodeURIComponent(spacePath)}`;
// Use the login redirect when the dataset is private or its visibility is unknown.
const target = requiresLogin
? `https://huggingface.co/login?next=${encodeURIComponent(spacePath)}`
: `https://huggingface.co${spacePath}`;
window.open(target, "_blank", "noopener,noreferrer");
};

Expand All @@ -148,6 +149,7 @@ const Upload = () => {
const handleUploadToHub = async () => {
if (!datasetInfo) return;

const isPrivate = uploadConfig.private;
setIsUploading(true);
try {
// Parse tags from input
Expand All @@ -161,17 +163,18 @@ const Upload = () => {
body: JSON.stringify({
dataset_repo_id: datasetInfo.dataset_repo_id,
tags,
private: uploadConfig.private,
private: isPrivate,
}),
});

const data = await response.json();

if (response.ok && data.success) {
setUploadSuccess(true);
const hubRepoId = data.hub_repo_id ?? datasetInfo.dataset_repo_id;
setUploadedDataset({ repoId: hubRepoId, private: isPrivate });
toast({
title: "Upload Successful!",
description: `Dataset ${datasetInfo.dataset_repo_id} has been uploaded to HuggingFace Hub.`,
description: `Dataset ${hubRepoId} has been uploaded to HuggingFace Hub.`,
});
} else {
const fallback = "Failed to upload dataset to HuggingFace Hub.";
Expand Down Expand Up @@ -267,7 +270,7 @@ const Upload = () => {
</div>

{/* Success State */}
{uploadSuccess && (
{uploadedDataset !== null && (
<div className="bg-green-900/20 border border-green-600 rounded-lg p-6 mb-8">
<div className="flex items-center gap-3 mb-4">
<CheckCircle className="w-6 h-6 text-green-500" />
Expand All @@ -281,15 +284,7 @@ const Upload = () => {
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={() => {
const spacePath = `/spaces/lerobot/visualize_dataset?path=${encodeURIComponent(
`/${datasetInfo.dataset_repo_id}`
)}`;
const target = uploadConfig.private
? `https://huggingface.co/login?next=${encodeURIComponent(spacePath)}`
: `https://huggingface.co${spacePath}`;
window.open(target, "_blank", "noopener,noreferrer");
}}
onClick={() => openInHubViewer(uploadedDataset.repoId, uploadedDataset.private)}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<ExternalLink className="w-4 h-4 mr-2" />
Expand Down
63 changes: 48 additions & 15 deletions lelab/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from datetime import datetime
from typing import Any

from huggingface_hub.utils import validate_repo_id
from pydantic import BaseModel, field_validator

from lerobot.configs.dataset import DatasetRecordConfig
Expand All @@ -31,7 +32,8 @@
from lerobot.teleoperators.so_leader import SO101LeaderConfig

from .camera_config import lerobot_camera_settings, validate_camera_rotations
from .episode_media import UnreadableDatasetError, ensure_episode_index
from .episode_media import UnreadableDatasetError, ensure_episode_index, resolve_dataset_dir
from .utils import hf_auth
from .utils.config import setup_calibration_files, with_lelab_tag
from .utils.devices import follower_lock, safe_disconnect_device

Expand Down Expand Up @@ -722,18 +724,52 @@ def handle_delete_dataset(request: DatasetInfoRequest) -> dict[str, Any]:
return {"success": True, "message": f"Deleted {repo_id}"}


def _upload_login_required() -> dict[str, Any]:
return {
"success": False,
"message": "You're not logged into the Hugging Face Hub. Run `hf auth login` in your terminal, then retry.",
"docs_url": "https://huggingface.co/docs/huggingface_hub/en/quick-start#authentication",
}


def handle_upload_dataset(request: UploadRequest) -> dict[str, Any]:
"""Handle dataset upload to HuggingFace Hub"""
try:
# Import LeRobotDataset to load and upload the dataset
from lerobot.datasets import LeRobotDataset

ensure_episode_index(request.dataset_repo_id)

logger.info(f"Loading dataset {request.dataset_repo_id} for upload")

# Load the dataset from local storage
dataset = LeRobotDataset(request.dataset_repo_id)
source_repo_id = request.dataset_repo_id
hub_repo_id = source_repo_id
source_root = None
ensure_episode_index(source_repo_id)

if source_repo_id.startswith(f"{LOCAL_DATASET_NAMESPACE}/"):
source_root = resolve_dataset_dir(source_repo_id)
account = hf_auth.cached_whoami()
if not account or not account.get("name"):
return _upload_login_required()
hub_repo_id = f"{account['name']}/{source_repo_id.split('/', 1)[1]}"
validate_repo_id(hub_repo_id)

logger.info(f"Loading dataset {source_repo_id} for upload")
dataset = LeRobotDataset(source_repo_id)
if source_root is not None:
if dataset.root.resolve() != source_root:
raise UnreadableDatasetError(f"{source_repo_id} could not be loaded from its local directory")
if request.private:
# Hub ignores private=True when the repository already exists.
api = hf_auth.shared_hf_api()
api.create_repo(repo_id=hub_repo_id, repo_type="dataset", private=True, exist_ok=True)
if api.dataset_info(hub_repo_id).private is not True:
return {
"success": False,
"message": (
f"Cannot upload privately to {hub_repo_id}: the destination must be private. "
"Set the repository to private on the Hub or use another dataset name."
),
}
# Keep the source ID while reading. The uploader uses repo_id for the destination.
dataset.repo_id = hub_repo_id

logger.info(f"Dataset loaded with {dataset.num_episodes} episodes")
tags = with_lelab_tag(request.tags)
Expand All @@ -742,12 +778,13 @@ def handle_upload_dataset(request: UploadRequest) -> dict[str, Any]:
# Upload dataset to HuggingFace Hub
dataset.push_to_hub(tags=tags, private=request.private)

logger.info(f"Dataset {request.dataset_repo_id} uploaded successfully to HuggingFace Hub")
logger.info(f"Dataset {hub_repo_id} uploaded successfully to HuggingFace Hub")

return {
"success": True,
"message": f"Dataset {request.dataset_repo_id} uploaded successfully to HuggingFace Hub",
"dataset_url": f"https://huggingface.co/datasets/{request.dataset_repo_id}",
"message": f"Dataset {hub_repo_id} uploaded successfully to HuggingFace Hub",
"hub_repo_id": hub_repo_id,
"dataset_url": f"https://huggingface.co/datasets/{hub_repo_id}",
"num_episodes": dataset.num_episodes,
}

Expand All @@ -764,11 +801,7 @@ def handle_upload_dataset(request: UploadRequest) -> dict[str, Any]:
for m in ("401", "you must be authenticated", "authentication required", "huggingfacehub_token")
)
if looks_like_auth:
return {
"success": False,
"message": "You're not logged into the Hugging Face Hub. Run `hf auth login` in your terminal, then retry.",
"docs_url": "https://huggingface.co/docs/huggingface_hub/en/quick-start#authentication",
}
return _upload_login_required()
return {"success": False, "message": f"Failed to upload dataset: {str(e)}"}


Expand Down
Loading