# 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