e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
275 lines
9.7 KiB
Python
275 lines
9.7 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Tests for the S3 dataset loader (core.training.s3_dataset).
|
|
|
|
boto3 is optional and may be absent in CI, so the S3 client is mocked: a fake
|
|
client provides a paginator over a synthetic bucket listing and writes files on
|
|
download_file. No network or real AWS credentials are involved.
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# Load the modules under test directly by path. Importing them through their
|
|
# packages (core.training / models) would execute heavy package __init__ chains
|
|
# (structlog, torch, …) that aren't needed for these unit tests.
|
|
_BACKEND = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _load(mod_name, rel_path):
|
|
spec = importlib.util.spec_from_file_location(mod_name, _BACKEND / rel_path)
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
s3_dataset = _load("s3_dataset", "core/training/s3_dataset.py")
|
|
S3Config = _load("models_training_s3", "models/training.py").S3Config
|
|
|
|
|
|
class _FakePaginator:
|
|
def __init__(self, keys):
|
|
self._keys = keys
|
|
|
|
def paginate(self, **kwargs):
|
|
prefix = kwargs.get("Prefix")
|
|
contents = [{"Key": k} for k in self._keys if prefix is None or k.startswith(prefix)]
|
|
# Emit in two pages to exercise pagination handling.
|
|
mid = len(contents) // 2
|
|
yield {"Contents": contents[:mid]}
|
|
yield {"Contents": contents[mid:]}
|
|
|
|
|
|
class _FakeS3Client:
|
|
def __init__(self, keys):
|
|
self._keys = keys
|
|
self.downloaded = []
|
|
|
|
def get_paginator(self, name):
|
|
assert name == "list_objects_v2"
|
|
return _FakePaginator(self._keys)
|
|
|
|
def download_file(self, bucket, key, local_path, **kwargs):
|
|
self.downloaded.append((bucket, key, local_path))
|
|
callback = kwargs.get("Callback")
|
|
if callback is not None:
|
|
callback(1)
|
|
with open(local_path, "w", encoding = "utf-8") as f:
|
|
f.write(f"content-of:{key}")
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_client(monkeypatch):
|
|
"""Force boto3_available True and stub the client builder."""
|
|
keys = [
|
|
"datasets/train.parquet",
|
|
"datasets/extra.parquet",
|
|
"datasets/notes.txt", # filtered out (unsupported)
|
|
"datasets/subdir/", # directory placeholder, skipped
|
|
"other/ignore.parquet", # filtered out by prefix
|
|
]
|
|
client = _FakeS3Client(keys)
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
return client
|
|
|
|
|
|
def _cfg(**overrides):
|
|
base = {
|
|
"bucket": "my-bucket",
|
|
"region": "us-east-1",
|
|
"prefix": "datasets/",
|
|
"access_key_id": "AKIA_TEST",
|
|
"secret_access_key": "secret",
|
|
"use_iam_role": False,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def test_downloads_only_supported_files_under_prefix(fake_client, tmp_path):
|
|
files = s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
names = sorted(os.path.basename(f) for f in files)
|
|
# txt is unsupported, the directory placeholder is skipped, and the
|
|
# "other/" key is excluded by the prefix filter.
|
|
assert names == ["extra.parquet", "train.parquet"]
|
|
for f in files:
|
|
assert os.path.exists(f)
|
|
|
|
|
|
def test_allows_json_and_jsonl_family(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(["datasets/train.json", "datasets/extra.jsonl"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
|
|
files = s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
assert sorted(os.path.basename(f) for f in files) == ["extra.jsonl", "train.json"]
|
|
|
|
|
|
def test_ignores_common_json_metadata_files(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(
|
|
[
|
|
"datasets/train.parquet",
|
|
"datasets/schema.json",
|
|
"datasets/metadata.json",
|
|
"datasets/dataset_info.json",
|
|
]
|
|
)
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
|
|
files = s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
assert [os.path.basename(f) for f in files] == ["train.parquet"]
|
|
|
|
|
|
def test_raises_when_prefix_contains_mixed_formats(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(["datasets/train.parquet", "datasets/stray.csv"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
|
|
with pytest.raises(ValueError, match = "mixed dataset formats"):
|
|
s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
assert client.downloaded == []
|
|
|
|
|
|
def test_raises_when_no_supported_files(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(["datasets/readme.txt"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
with pytest.raises(ValueError, match = "No supported dataset files"):
|
|
s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
|
|
def test_raises_when_boto3_missing(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: False)
|
|
with pytest.raises(RuntimeError, match = "requires boto3"):
|
|
s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
|
|
def test_basename_collisions_are_disambiguated(monkeypatch, tmp_path):
|
|
# Two keys share a basename under different sub-prefixes.
|
|
client = _FakeS3Client(["datasets/a/train.parquet", "datasets/b/train.parquet"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
files = s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
assert len(files) == 2
|
|
assert len(set(files)) == 2 # no overwrite
|
|
|
|
|
|
def test_basename_collision_skips_existing_generated_suffix(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(
|
|
[
|
|
"datasets/a/train.parquet",
|
|
"datasets/b/train_1.parquet",
|
|
"datasets/c/train.parquet",
|
|
]
|
|
)
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
|
|
files = s3_dataset.download_s3_dataset(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
assert [os.path.basename(f) for f in files] == [
|
|
"train.parquet",
|
|
"train_1.parquet",
|
|
"train_2.parquet",
|
|
]
|
|
assert len(set(files)) == 3
|
|
assert (tmp_path / "train_1.parquet").read_text(encoding = "utf-8") == (
|
|
"content-of:datasets/b/train_1.parquet"
|
|
)
|
|
assert (tmp_path / "train_2.parquet").read_text(encoding = "utf-8") == (
|
|
"content-of:datasets/c/train.parquet"
|
|
)
|
|
|
|
|
|
def test_download_handle_cleans_owned_temp_dir(monkeypatch, tmp_path):
|
|
target_dir = tmp_path / "owned-download"
|
|
client = _FakeS3Client(["datasets/train.parquet"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
monkeypatch.setattr(s3_dataset.tempfile, "mkdtemp", lambda prefix: str(target_dir))
|
|
|
|
download = s3_dataset.prepare_s3_dataset_download(_cfg())
|
|
|
|
assert target_dir.exists()
|
|
assert download.files == [str(target_dir / "train.parquet")]
|
|
download.cleanup()
|
|
assert not target_dir.exists()
|
|
|
|
|
|
def test_dest_dir_is_not_removed_by_cleanup(monkeypatch, tmp_path):
|
|
client = _FakeS3Client(["datasets/train.parquet"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
|
|
download = s3_dataset.prepare_s3_dataset_download(_cfg(), dest_dir = str(tmp_path))
|
|
|
|
download.cleanup()
|
|
assert tmp_path.exists()
|
|
assert (tmp_path / "train.parquet").exists()
|
|
|
|
|
|
def test_cancel_callback_aborts_and_removes_temp_dir(monkeypatch, tmp_path):
|
|
target_dir = tmp_path / "cancelled-download"
|
|
client = _FakeS3Client(["datasets/train.parquet"])
|
|
monkeypatch.setattr(s3_dataset, "boto3_available", lambda: True)
|
|
monkeypatch.setattr(s3_dataset, "_build_s3_client", lambda cfg: client)
|
|
monkeypatch.setattr(s3_dataset.tempfile, "mkdtemp", lambda prefix: str(target_dir))
|
|
calls = 0
|
|
|
|
def cancel_after_download_starts():
|
|
nonlocal calls
|
|
calls += 1
|
|
return calls >= 4
|
|
|
|
with pytest.raises(s3_dataset.S3DownloadCancelled):
|
|
s3_dataset.prepare_s3_dataset_download(
|
|
_cfg(),
|
|
cancel_callback = cancel_after_download_starts,
|
|
)
|
|
|
|
assert not target_dir.exists()
|
|
|
|
|
|
# ── S3Config model (camelCase aliases + credential validation) ──
|
|
|
|
|
|
def test_s3config_accepts_camelcase_aliases():
|
|
cfg = S3Config.model_validate(
|
|
{
|
|
"bucket": "b",
|
|
"region": "eu-west-1",
|
|
"accessKeyId": "AKIA",
|
|
"secretAccessKey": "shh",
|
|
}
|
|
)
|
|
assert cfg.access_key_id == "AKIA"
|
|
assert cfg.secret_access_key == "shh"
|
|
# model_dump() yields snake_case for the loader.
|
|
assert cfg.model_dump()["access_key_id"] == "AKIA"
|
|
|
|
|
|
def test_s3config_accepts_snake_case():
|
|
cfg = S3Config.model_validate(
|
|
{"bucket": "b", "access_key_id": "AKIA", "secret_access_key": "shh"}
|
|
)
|
|
assert cfg.access_key_id == "AKIA"
|
|
|
|
|
|
def test_s3config_requires_credentials_or_iam():
|
|
with pytest.raises(ValueError):
|
|
S3Config.model_validate({"bucket": "b"})
|
|
|
|
|
|
def test_s3config_iam_role_needs_no_keys():
|
|
cfg = S3Config.model_validate({"bucket": "b", "useIamRole": True})
|
|
assert cfg.use_iam_role is True
|