Skip to content

Commit 235d4c6

Browse files
authored
feat(training): add NVIDIA ARM64 support for LeRobot 0.6.1 (#1407)
# Pull Request ## Description <!-- Brief description of changes. Link related issues using Closes #123 --> Adds NVIDIA ARM64 (Jetson Thor / CUDA 13) support so LeRobot imitation-learning work can run on local ARM64 hardware as well as the existing x86_64 AzureML and OSMO targets, and bumps LeRobot to **0.6.1**. The dataset-consuming code paths were updated for LeRobot's newer shared-file layout, and several related correctness fixes travel with the change. > The decoder choice drives much of this PR. LeRobot's `dataset` extra requires TorchCodec, which publishes no SBSA CUDA 13 wheel, and `get_safe_default_video_backend()` selects TorchCodec whenever it is importable. Keeping TorchCodec on x86_64 only would have decoded training and evaluation frames through different backends per architecture, so the branch standardizes on **PyAV** everywhere. ### Dependencies - Moved `training/il/lerobot` from `lerobot[dataset]==0.6.0` to `lerobot[av-dep]==0.6.1` and removed `torchcodec` as both a direct dependency and a resolution override. - Declared `pandas` and `jsonlines` directly, since *lerobot.datasets* imports them eagerly and they shipped in the extra that is no longer selected. Runtime installs use `uv pip install --no-deps`, so both must be in the lock. - Removed the `wandb` pin. All three LeRobot entry points (*azureml-train-entry.sh*, *lerobot-train-osmo-entry.sh*, and *train.py*) pass `--wandb.enable=false`, and `wandb` only appears in LeRobot's unselected `training` extra. - Widened `[tool.uv].environments` to cover `x86_64` and `aarch64`, and added the `jetson-ai-lab-cu130` index as an `explicit` source for ARM64 `torch` and `torchvision` only. - Existing CVE-motivated pins (`gitpython`, `urllib3`, `cryptography`) are unchanged. ### Dev container - Rebuilt *.devcontainer/Dockerfile* as a two-stage image. The CUDA stage is digest-pinned, installs exact-version CUDA and CUPTI packages, and adds cuDSS plus NVPL BLAS/LAPACK on arm64 only. The final base moved to `devcontainers/base:ubuntu-24.04`. - Added `hostRequirements.gpu: "optional"` so GPU-less hosts still build, plus the `--group-add` entries CUDA needs. `/dev/nvmap` is owned by `video` and `/dev/dri/renderD*` by `render`; without both, `cudaGetDeviceCount()` fails with error 801 while `nvidia-smi` still looks healthy. - Reworked the `osmo-cli` install from an x86_64-only skip into an architecture dispatch that installs the checksum-verified OSMO 6.3.0 client on `arm64` as well. ### Data viewer - **Blob sync** now requests blob metadata and skips ADLS Gen2 directory blobs (`hdi_isfolder`) along with HuggingFace `.cache` entries. Writing a directory blob as a file previously blocked creation of the directory beneath it. - **Cached episode clips** are validated with a bounded *ffmpeg* probe before being served; invalid clips are deleted and regenerated. - **TrajectoryEditor** was split into a wrapper and an inner frame component keyed by episode and frame, replacing a synchronization effect that discarded in-progress edits whenever the adjustments map changed for an unrelated frame. Clearing all adjustments now also resets the editor inputs, which the previous version left showing stale deltas. ### Evaluation and training scripts - *run-local-lerobot-eval.py* reads the `meta/episodes/**.parquet` metadata layout, resolves data and video files through the `data_path` and `video_path` templates, filters rows by `episode_index`, and decodes only each episode's timestamp window. The legacy chunk-scan and `episodes.jsonl` paths remain as fallbacks, and metadata lookups are guarded so datasets lacking the newer columns fall back rather than raising. - *download_dataset.py* skips ADLS directory-marker blobs instead of trying to download them as files. ### Infrastructure - *03-deploy-osmo.sh* labels the pre-created `osmo-default-admin` secret, plus `db-secret` and `redis-secret` when they are Key Vault-backed, with `secrets-store.csi.k8s.io/managed=true`. The CSI driver otherwise refuses to adopt secrets it does not own. The change is metadata-only and reads no secret material. None ## Type of Change <!-- Mark relevant options with [x] --> - [x] 🐛 Bug fix (non-breaking change fixing an issue) - [x] ✨ New feature (non-breaking change adding functionality) - [ ] 💥 Breaking change (fix or feature causing existing functionality to change) - [x] 📚 Documentation update - [ ] 🏗️ Infrastructure change (Terraform/IaC) - [ ] ♻️ Refactoring (no functional changes) <!-- Note: x86_64 video decoding moves from TorchCodec to PyAV. No API changes, but reviewers should confirm this is acceptable for existing AzureML and OSMO training runs. --> ## Component(s) Affected <!-- Mark all that apply --> - [ ] `infrastructure/terraform/prerequisites/` - Azure subscription setup - [ ] `infrastructure/terraform/` - Terraform infrastructure - [x] `infrastructure/setup/` - OSMO control plane / Helm - [ ] `workflows/` - Training and evaluation workflows - [x] `training/` - Training pipelines and scripts - [x] `docs/` - Documentation <!-- Also affected, not listed above: .devcontainer/, data-management/viewer/ (backend and frontend), evaluation/sil/ --> ## Testing Performed <!-- Describe testing. Check applicable items --> - [ ] Terraform `plan` reviewed (no unexpected changes) - [ ] Terraform `apply` tested in dev environment - [ ] Training scripts tested locally with Isaac Sim - [ ] OSMO workflow submitted successfully - [ ] Smoke tests passed (`smoke_test_azure.py`) None of the listed items apply: this PR changes no Terraform, submits no OSMO workflow, and touches no Isaac Sim path. Validation performed instead: | Check | Result | | --- | --- | | `npm run lint:md` (tracked files) | 0 issues | | `npm run spell-check` | 0 issues across 791 files | | `npm run format:tables` | clean | | `npm run lint:sh` | 95 files, 0 errors, 0 warnings | | `npm run lint:py` | passed | | `npm run lint:uvlock` (changed-files-only, as PR validation runs it) | passed | | Frontend `npm run validate` | 1669/1669 tests, 0 errors | | Backend `pytest` (changed modules) | 107 passed, 15 skipped | Hardware validation on an NVIDIA Thor (aarch64) host, inside the rebuilt dev container as the non-root `vscode` user: - `torch.cuda.is_available()` returned `True`, device reported as `NVIDIA Thor`, CUDA runtime 13.0, and a 2048x2048 matmul produced finite values. - `get_safe_default_video_backend()` resolved to `pyav` with `torchcodec` absent, confirming the decoder is consistent across architectures. - The `osmo` 6.3.0 arm64 client installed and reported its version. Environment: local NVIDIA Thor workstation only. No Azure resources were deployed and no cost was incurred. Known limitations: the x86_64 path was not executed on hardware, so the TorchCodec-to-PyAV decoder change is unverified for AzureML and OSMO training runs. LeRobot training itself was not run end-to-end on ARM64; validation covered CUDA availability, dependency resolution, and backend selection. ## Documentation Impact <!-- Select one --> - [ ] No documentation changes needed - [x] Documentation updated in this PR - [ ] Documentation issue filed *docs/contributing/prerequisites.md* gains a Dev Container GPU Runtime section covering host runtime configuration for CSV-mode ARM64 hosts and the device-group access requirements, including how to confirm the host `render` GID and the misleading error-801 symptom. *docs/getting-started/quickstart.md* cross-links to it. ## Bug Fix Checklist *Complete this section for bug fix PRs. Skip for other contribution types.* - [x] Linked to issue being fixed — Closes #1446 - [x] Regression test included, OR - [ ] Justification for no regression test: Regression coverage added for each fix: two TrajectoryEditor tests (pending edits surviving an unrelated store update, and inputs clearing on Clear All), blob-sync tests asserting directory and `.cache` blobs are not downloaded, and a handler test proving an invalid cached clip is regenerated. The TrajectoryEditor tests were confirmed to fail against the pre-fix implementations. ## Checklist - [x] My code follows the [project conventions](copilot-instructions.md) - [x] Commit messages follow [conventional commit format](instructions/commit-message.instructions.md) - [x] I have performed a self-review - [x] Documentation impact assessed above - [x] No new linting warnings introduced ## Notes The 39 frontend lint warnings are pre-existing `react-hooks/set-state-in-effect` findings; this PR removes one such effect and adds none. `npm run lint:uvlock` passes for all 13 locked Python projects after the review update. Follow-up candidates, all evidenced in the diff: - The `datasets==5.0.0` override comment in *training/il/lerobot/pyproject.toml* cites a cap from LeRobot's `dataset` extra, which is no longer selected; confirm whether the override is still required. - The `render` GID `993` in *.devcontainer/devcontainer.json* is host-specific. It is documented in both the file comment and the prerequisites guide, but it is not resolved dynamically. ## Review amendment — September 1, 2026 Closes #1446 Reviewer feedback is addressed in `a43555e0`: - Digest-pinned the final dev-container runtime image and removed the unrestricted base-image build argument so container discovery scans the shipped image. - Enabled all NVIDIA driver capabilities, including Vulkan. - Contained metadata-template and legacy video paths within the dataset root, rejecting absolute paths and traversal. - Added real LeRobot shared-file integration coverage for metadata loading, Parquet episode filtering, path templates, timestamp-window video decoding, empty episodes, and legacy fallbacks. - Added direct ffmpeg probe coverage for valid clips, failures, timeouts, and environments without ffmpeg. - Added PyAV and PyArrow to the evaluation CI test group so integration coverage executes rather than skips. - Isolated Dataviewer API tests from developer Azure storage configuration to prevent credential retries. Validation after the review fixes: evaluation 300 passed with 96.25% coverage; Dataviewer backend 1,136 passed; Dataviewer frontend 1,669 passed; LeRobot download tests 67 passed; Python lint, uv lock consistency, and focused container-image discovery tests passed. Reviewer-provided AzureML and OSMO end-to-end runs also passed.
1 parent 08c2972 commit 235d4c6

23 files changed

Lines changed: 1453 additions & 501 deletions

File tree

.cspell.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"headlessly",
2626
"hfpins",
2727
"imwrite",
28+
"isfolder",
2829
"leisaac",
2930
"minimise",
3031
"misattributed",
@@ -33,6 +34,7 @@
3334
"mycontainer",
3435
"noout",
3536
"noseparator",
37+
"nvmap",
3638
"pixelformat",
3739
"plottable",
3840
"pollable",
@@ -52,6 +54,7 @@
5254
"serialised",
5355
"serialises",
5456
"sparkline",
57+
"tegra",
5558
"trackio",
5659
"unbuilt",
5760
"unimportable",

.devcontainer/Dockerfile

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
1-
ARG BASE_IMAGE=
2-
FROM ${BASE_IMAGE:-mcr.microsoft.com/devcontainers/python:3.12-bookworm}
1+
FROM nvidia/cuda:13.0.3-cudnn-runtime-ubuntu24.04@sha256:14f6d08d1cd4a96effbfe3101d0b56326f552c199d05e4979ee0bd616df5811b AS cuda-runtime
2+
RUN apt-get update && \
3+
apt-get install -y --no-install-recommends cuda-cupti-13-0=13.0.85-1 && \
4+
mkdir /opt/nvpl && \
5+
if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
6+
apt-get install -y --no-install-recommends libcudss0-cuda-13=0.8.0.10-1 libnvpl-blas0=0.6.0-1 libnvpl-lapack0=0.4.0.1-1 && \
7+
cp -a /usr/lib/*-linux-gnu/libcudss/13/libcudss*.so* /usr/lib/*-linux-gnu/libnvpl*.so* /opt/nvpl/; \
8+
fi && \
9+
rm -rf /var/lib/apt/lists/*
10+
11+
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae
12+
13+
RUN apt-get update && \
14+
apt-get install -y --no-install-recommends python3 python3-venv python-is-python3 && \
15+
rm -rf /var/lib/apt/lists/*
16+
17+
COPY --from=cuda-runtime /usr/local/cuda-13.0 /usr/local/cuda-13.0
18+
COPY --from=cuda-runtime /usr/lib/*-linux-gnu/libcudnn*.so* /usr/local/cuda-13.0/lib64/
19+
COPY --from=cuda-runtime /opt/nvpl/ /usr/local/cuda-13.0/lib64/
20+
RUN ln -s /usr/local/cuda-13.0 /usr/local/cuda
21+
ENV PATH="/usr/local/cuda/bin:${PATH}"
22+
ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64"
323

424
ARG NPM_CONFIG_REGISTRY=
525
ENV NPM_CONFIG_REGISTRY=${NPM_CONFIG_REGISTRY:-https://registry.npmjs.org/}
@@ -8,4 +28,6 @@ ARG PIP_INDEX_URL=
828
ENV PIP_INDEX_URL=${PIP_INDEX_URL:-https://pypi.org/simple/}
929

1030
ARG UV_DEFAULT_INDEX=
11-
ENV UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX:-https://pypi.org/simple/}
31+
# No trailing slash: uv records this URL verbatim in uv.lock, so a slash here
32+
# makes every locally regenerated lock drift from the CI-generated one.
33+
ENV UV_DEFAULT_INDEX=${UV_DEFAULT_INDEX:-https://pypi.org/simple}

.devcontainer/devcontainer.json

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
"build": {
77
"dockerfile": "Dockerfile",
88
"args": {
9-
"BASE_IMAGE": "${localEnv:PHYSICAL_AI_DEVCONTAINER_IMAGE}",
109
"NPM_CONFIG_REGISTRY": "${localEnv:NPM_CONFIG_REGISTRY}",
1110
"PIP_INDEX_URL": "${localEnv:PIP_INDEX_URL}",
1211
"UV_DEFAULT_INDEX": "${localEnv:UV_DEFAULT_INDEX}"
@@ -92,10 +91,27 @@
9291
// Use 'forwardPorts' to make a list of ports inside the container available locally.
9392
"forwardPorts": [8080],
9493

94+
// GPU device nodes are group-restricted: /dev/nvmap is root:video and
95+
// /dev/dri/renderD* is root:render (GID 993 here). CUDA initialization fails
96+
// with error 801 unless the container user holds both; adjust 993 if the host
97+
// render group differs. "gpu": "optional" attaches devices when present and
98+
// still builds on GPU-less hosts.
99+
"runArgs": [
100+
"--group-add",
101+
"video",
102+
"--group-add",
103+
"993"
104+
],
105+
"hostRequirements": {
106+
"gpu": "optional"
107+
},
108+
95109
// Container environment variables
96110
"containerEnv": {
97111
"BROWSER": "code --new-window",
98-
"UV_LINK_MODE": "copy"
112+
"UV_LINK_MODE": "copy",
113+
"NVIDIA_VISIBLE_DEVICES": "all",
114+
"NVIDIA_DRIVER_CAPABILITIES": "all"
99115
},
100116

101117
"onCreateCommand": {
@@ -106,7 +122,7 @@
106122
"actionlint": "./scripts/setup/install-actionlint.sh",
107123
"golangci-lint": "GOLANGCI_LINT_VERSION=2.12.2 && case \"$(uname -m)\" in x86_64) GOLANGCI_LINT_ARCH=amd64; GOLANGCI_LINT_SHA256=8df580d2670fed8fa984aac0507099af8df275e665215f5c7a2ae3943893a553 ;; aarch64|arm64) GOLANGCI_LINT_ARCH=arm64; GOLANGCI_LINT_SHA256=44cd40a8c76c86755375adfeea52cfd3533cb43d7bd647771e0ae065e166df3a ;; *) echo \"Unsupported arch: $(uname -m)\" >&2; exit 1 ;; esac && curl -fsSL \"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${GOLANGCI_LINT_ARCH}.tar.gz\" -o /tmp/golangci-lint.tar.gz && echo \"${GOLANGCI_LINT_SHA256} /tmp/golangci-lint.tar.gz\" | sha256sum -c --quiet - && sudo tar -xzf /tmp/golangci-lint.tar.gz -C /usr/local/bin --strip-components=1 \"golangci-lint-${GOLANGCI_LINT_VERSION}-linux-${GOLANGCI_LINT_ARCH}/golangci-lint\" && rm /tmp/golangci-lint.tar.gz",
108124
"update-bashrc": "echo 'export PATH=\"${containerWorkspaceFolder}/scripts:${containerWorkspaceFolder}/node_modules/.bin:$PATH\"' | sudo tee -a ~/.bashrc",
109-
"osmo-cli": "if [ \"$(uname -m)\" != \"x86_64\" ]; then echo \"Skipping osmo-cli: no $(uname -m) build available (x86_64 only)\"; exit 0; fi && OSMO_VERSION=6.3.0 && OSMO_INSTALLER_SHA256=e2e70b4868fe2ee22c7952f2d53d1e013070168b77e89f51e8c52a0eaaf18928 && curl -fsSL \"https://github.com/NVIDIA/OSMO/releases/download/${OSMO_VERSION}/osmo-client-installer-${OSMO_VERSION}-linux-x86_64.sh\" -o /tmp/osmo_install.sh && echo \"${OSMO_INSTALLER_SHA256} /tmp/osmo_install.sh\" | sha256sum -c --quiet - && sudo bash /tmp/osmo_install.sh && rm /tmp/osmo_install.sh",
125+
"osmo-cli": "OSMO_VERSION=6.3.0 && case \"$(uname -m)\" in x86_64) OSMO_ARCH=x86_64; OSMO_INSTALLER_SHA256=e2e70b4868fe2ee22c7952f2d53d1e013070168b77e89f51e8c52a0eaaf18928 ;; aarch64|arm64) OSMO_ARCH=arm64; OSMO_INSTALLER_SHA256=03d559d8a999b4d6f2ac6aac989d17792a47aaee70b5c7fc5787655f123923ee ;; *) echo \"Unsupported arch: $(uname -m)\" >&2; exit 1 ;; esac && curl -fsSL \"https://github.com/NVIDIA/OSMO/releases/download/${OSMO_VERSION}/osmo-client-installer-${OSMO_VERSION}-linux-${OSMO_ARCH}.sh\" -o /tmp/osmo_install.sh && echo \"${OSMO_INSTALLER_SHA256} /tmp/osmo_install.sh\" | sha256sum -c --quiet - && sudo bash /tmp/osmo_install.sh && rm /tmp/osmo_install.sh",
110126
"ngc-cli": "if [ \"$(uname -m)\" != \"x86_64\" ]; then echo \"Skipping ngc-cli: no $(uname -m) build available (x86_64 only)\"; exit 0; fi && NGC_CLI_VERSION=3.41.4 && NGC_CLI_SHA256=2c86681048ab8e2980bdd6aa6c17f086eff988276ad90a28f1307b69fdb50252 && curl -fsSL \"https://api.ngc.nvidia.com/v2/resources/nvidia/ngc-apps/ngc_cli/versions/${NGC_CLI_VERSION}/files/ngccli_linux.zip\" -o /tmp/ngccli.zip && echo \"${NGC_CLI_SHA256} /tmp/ngccli.zip\" | sha256sum -c --quiet - && sudo unzip -o /tmp/ngccli.zip -d /usr/local && rm /tmp/ngccli.zip && sudo chmod u+x /usr/local/ngc-cli/ngc && sudo ln -sf /usr/local/ngc-cli/ngc /usr/local/bin/ngc",
111127
"az-ml": "az extension add -n ml --yes || true"
112128
},

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ project.lock.json
7575
project.fragment.lock.json
7676
artifacts/
7777
**/.artifacts/
78+
/outputs/
7879

7980
# ASP.NET Scaffolding
8081
ScaffoldingReadMe.txt
@@ -344,6 +345,7 @@ paket-files/
344345

345346
# Python Tools for Visual Studio (PTVS)
346347
**/__pycache__/
348+
**/*.egg-info/
347349
*.pyc
348350

349351
# Hypothesis testing framework cache

data-management/viewer/backend/src/api/services/dataset_service/lerobot_handler.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,9 @@ def get_video_path(self, dataset_id: str, episode_idx: int, camera: str) -> str
475475
if clip_path is None:
476476
return str(video_path)
477477

478-
if clip_path.exists():
478+
if clip_path.exists() and self._is_valid_video_file(clip_path):
479479
return str(clip_path)
480+
clip_path.unlink(missing_ok=True)
480481

481482
if self._generate_episode_video_clip(video_path, window, clip_path):
482483
return str(clip_path)
@@ -495,6 +496,35 @@ def _video_cache_path(self, dataset_id: str, episode_idx: int, camera: str) -> P
495496
safe_camera = camera.replace("/", "_").replace("\\", "_")
496497
return loader.base_path / "meta" / "videos" / safe_camera / f"episode_{episode_idx:06d}.mp4"
497498

499+
@staticmethod
500+
def _is_valid_video_file(video_path: Path) -> bool:
501+
ffmpeg = LeRobotFormatHandler._resolve_ffmpeg()
502+
if ffmpeg is None:
503+
return video_path.stat().st_size > 0
504+
505+
try:
506+
proc = subprocess.run(
507+
[
508+
ffmpeg,
509+
"-v",
510+
"error",
511+
"-i",
512+
str(video_path),
513+
"-map",
514+
"0:v:0",
515+
"-frames:v",
516+
"1",
517+
"-f",
518+
"null",
519+
"-",
520+
],
521+
capture_output=True,
522+
timeout=10,
523+
)
524+
return proc.returncode == 0
525+
except (OSError, subprocess.SubprocessError):
526+
return False
527+
498528
@staticmethod
499529
def _generate_episode_video_clip(source_path: Path, window: tuple[float, float], clip_path: Path) -> bool:
500530
ffmpeg = LeRobotFormatHandler._resolve_ffmpeg()

data-management/viewer/backend/src/api/storage/blob_dataset.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -606,15 +606,20 @@ async def sync_dataset_to_local(self, dataset_id: str, local_dir: Path) -> bool:
606606
prefix = f"{self.get_blob_prefix(dataset_id)}/"
607607
synced_count = 0
608608

609-
async for blob in container.list_blobs(name_starts_with=prefix):
609+
async for blob in container.list_blobs(name_starts_with=prefix, include=["metadata"]):
610+
if self._is_directory_blob(blob):
611+
continue
612+
613+
relative = blob.name[len(prefix) :]
614+
if relative == ".cache" or relative.startswith(".cache/"):
615+
continue
610616
# Skip video files — they are streamed on demand
611617
if "/videos/" in blob.name:
612618
continue
613619
# Skip HDF5 files — they are downloaded on demand per episode
614620
if blob.name.endswith(".hdf5"):
615621
continue
616622

617-
relative = blob.name[len(prefix) :]
618623
local_path = local_dir / relative
619624
local_path.parent.mkdir(parents=True, exist_ok=True)
620625

@@ -642,6 +647,12 @@ async def sync_dataset_to_local(self, dataset_id: str, local_dir: Path) -> bool:
642647
)
643648
return False
644649

650+
@staticmethod
651+
def _is_directory_blob(blob: object) -> bool:
652+
"""Return whether a listed blob is an ADLS Gen2 directory resource."""
653+
metadata = getattr(blob, "metadata", None)
654+
return isinstance(metadata, dict) and str(metadata.get("hdi_isfolder", "")).lower() == "true"
655+
645656
async def sync_meta_only_to_local(self, dataset_id: str, local_dir: Path) -> bool:
646657
"""
647658
Download only meta/ files for a dataset to a local directory.
@@ -665,7 +676,10 @@ async def sync_meta_only_to_local(self, dataset_id: str, local_dir: Path) -> boo
665676
prefix = self.get_blob_prefix(dataset_id)
666677
meta_prefix = f"{prefix}/meta/"
667678

668-
async for blob in container.list_blobs(name_starts_with=meta_prefix):
679+
async for blob in container.list_blobs(name_starts_with=meta_prefix, include=["metadata"]):
680+
if self._is_directory_blob(blob):
681+
continue
682+
669683
relative = blob.name[len(f"{prefix}/") :]
670684
if relative not in _SYNC_META_BLOBS and not relative.startswith("meta/episodes/"):
671685
continue

data-management/viewer/backend/tests/api/test_annotations.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
"""
44

55
import asyncio
6-
import os
76
import tempfile
87
from datetime import UTC, datetime
98

@@ -29,10 +28,11 @@
2928

3029

3130
@pytest.fixture
32-
def client():
31+
def client(monkeypatch):
3332
"""Create test client with isolated singletons and empty temp data path."""
3433
with tempfile.TemporaryDirectory() as tmp:
35-
os.environ["DATA_DIR"] = tmp
34+
monkeypatch.setenv("DATA_DIR", tmp)
35+
monkeypatch.setenv("STORAGE_BACKEND", "local")
3636

3737
import src.api.config as config_mod
3838
import src.api.services.annotation_service as ann_mod

data-management/viewer/backend/tests/api/test_auth.py

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Unit tests for authentication and CSRF middleware."""
22

3-
import os
3+
from __future__ import annotations
44

55
import pytest
66
from fastapi.testclient import TestClient
@@ -9,49 +9,43 @@
99

1010

1111
@pytest.fixture(autouse=True)
12-
def reset_auth_state(tmp_path):
13-
"""Reset the auth provider singleton and set a valid DATA_DIR before each test."""
12+
def reset_auth_state(tmp_path, monkeypatch):
13+
"""Reset application state and use isolated local storage for each test."""
1414
from src.api import auth as auth_mod
15+
from src.api import config as config_mod
16+
from src.api import main as main_mod
1517

16-
os.environ["DATA_DIR"] = str(tmp_path)
18+
monkeypatch.setenv("DATA_DIR", str(tmp_path))
19+
monkeypatch.setenv("STORAGE_BACKEND", "local")
20+
config_mod._app_config = None
21+
monkeypatch.setattr(main_mod, "_config", config_mod.load_config())
1722
auth_mod.reset_auth_provider()
1823
yield
1924
auth_mod.reset_auth_provider()
20-
os.environ.pop("DATA_DIR", None)
25+
config_mod._app_config = None
2126

2227

2328
@pytest.fixture
24-
def client_with_auth():
29+
def client_with_auth(monkeypatch):
2530
"""Test client with authentication enabled and API-key provider configured."""
2631
import src.api.services.dataset_service as ds_mod
2732

2833
ds_mod._dataset_service = None
29-
original_disabled = os.environ.pop("DATAVIEWER_AUTH_DISABLED", None)
30-
os.environ["DATAVIEWER_AUTH_PROVIDER"] = "apikey"
31-
os.environ["DATAVIEWER_API_KEY"] = "test-secret-key"
34+
monkeypatch.delenv("DATAVIEWER_AUTH_DISABLED", raising=False)
35+
monkeypatch.setenv("DATAVIEWER_AUTH_PROVIDER", "apikey")
36+
monkeypatch.setenv("DATAVIEWER_API_KEY", "test-secret-key")
3237
yield TestClient(app)
33-
if original_disabled is not None:
34-
os.environ["DATAVIEWER_AUTH_DISABLED"] = original_disabled
35-
else:
36-
os.environ.pop("DATAVIEWER_AUTH_DISABLED", None)
37-
os.environ.pop("DATAVIEWER_AUTH_PROVIDER", None)
38-
os.environ.pop("DATAVIEWER_API_KEY", None)
3938
ds_mod._dataset_service = None
4039

4140

4241
@pytest.fixture
43-
def client_auth_disabled():
42+
def client_auth_disabled(monkeypatch):
4443
"""Test client with auth disabled (DATAVIEWER_AUTH_DISABLED=true)."""
4544
import src.api.services.dataset_service as ds_mod
4645

4746
ds_mod._dataset_service = None
48-
original = os.environ.get("DATAVIEWER_AUTH_DISABLED")
49-
os.environ["DATAVIEWER_AUTH_DISABLED"] = "true"
47+
monkeypatch.setenv("DATAVIEWER_AUTH_DISABLED", "true")
5048
yield TestClient(app)
51-
if original is not None:
52-
os.environ["DATAVIEWER_AUTH_DISABLED"] = original
53-
else:
54-
os.environ.pop("DATAVIEWER_AUTH_DISABLED", None)
5549
ds_mod._dataset_service = None
5650

5751

data-management/viewer/backend/tests/storage/test_blob_dataset.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ def _make_blob(name: str):
3232
return blob
3333

3434

35+
def _make_directory_blob(name: str):
36+
blob = _make_blob(name)
37+
blob.metadata = {"hdi_isfolder": "true"}
38+
return blob
39+
40+
3541
def _build_provider(mock_client=None):
3642
from src.api.storage.blob_dataset import BlobDatasetProvider
3743

@@ -544,7 +550,14 @@ def test_sync_dataset_skips_videos_and_hdf5(self):
544550
"org/repo/extra/episode_0.hdf5", # skipped
545551
]
546552
mock_container = MagicMock()
547-
mock_container.list_blobs.return_value = _AsyncIter(_make_blob(n) for n in names)
553+
mock_container.list_blobs.return_value = _AsyncIter(
554+
[
555+
_make_directory_blob("org/repo/data"),
556+
_make_directory_blob("org/repo/data/chunk-000"),
557+
_make_blob("org/repo/.cache/huggingface/download.lock"),
558+
*(_make_blob(name) for name in names),
559+
]
560+
)
548561
mock_client = MagicMock()
549562
mock_client.get_container_client.return_value = mock_container
550563
provider = _build_provider(mock_client)
@@ -559,6 +572,7 @@ def test_sync_dataset_skips_videos_and_hdf5(self):
559572
assert (local_dir / "meta" / "info.json").read_bytes() == b"data"
560573
assert (local_dir / "data" / "chunk-000" / "file-000.parquet").exists()
561574
assert not (local_dir / "videos").exists()
575+
assert not (local_dir / ".cache").exists()
562576
assert read_mock.await_count == 2
563577

564578
@patch("src.api.storage.blob_dataset.AZURE_AVAILABLE", True)
@@ -584,7 +598,12 @@ def test_sync_meta_only_filters_to_allowed_blobs(self):
584598
"org/repo/meta/something_else.json", # filtered out
585599
]
586600
mock_container = MagicMock()
587-
mock_container.list_blobs.return_value = _AsyncIter(_make_blob(n) for n in names)
601+
mock_container.list_blobs.return_value = _AsyncIter(
602+
[
603+
_make_directory_blob("org/repo/meta/episodes"),
604+
*(_make_blob(name) for name in names),
605+
]
606+
)
588607
mock_client = MagicMock()
589608
mock_client.get_container_client.return_value = mock_container
590609
provider = _build_provider(mock_client)

0 commit comments

Comments
 (0)