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
569 lines
20 KiB
Python
569 lines
20 KiB
Python
"""Tests for check_dataset_for_missing_videos (issue #5085).
|
|
|
|
Fixtures AST-extract the function from vision.py so logic tests run without
|
|
the full unsloth import chain (triton/CUDA kernels).
|
|
"""
|
|
|
|
import ast
|
|
import os
|
|
import tempfile
|
|
import warnings
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _extract_fns_via_ast(
|
|
source_path,
|
|
fn_names,
|
|
extra_ns = None,
|
|
):
|
|
"""Exec the named top-level functions from a .py file so their mutual references resolve."""
|
|
source = source_path.read_text(encoding = "utf-8")
|
|
tree = ast.parse(source, filename = str(source_path))
|
|
wanted = set(fn_names)
|
|
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in wanted]
|
|
missing = wanted - {n.name for n in nodes}
|
|
if missing:
|
|
pytest.fail(f"{sorted(missing)} not found in {source_path}")
|
|
mini = ast.Module(body = nodes, type_ignores = [])
|
|
ast.fix_missing_locations(mini)
|
|
ns = {"os": os, "warnings": warnings, "__name__": "_extracted"}
|
|
if extra_ns:
|
|
ns.update(extra_ns)
|
|
exec(compile(mini, str(source_path), "exec"), ns)
|
|
return {name: ns[name] for name in fn_names}
|
|
|
|
|
|
def _extract_fn_via_ast(
|
|
source_path,
|
|
fn_name,
|
|
extra_ns = None,
|
|
):
|
|
return _extract_fns_via_ast(source_path, [fn_name], extra_ns)[fn_name]
|
|
|
|
|
|
@pytest.fixture(scope = "session")
|
|
def check_dataset_for_missing_videos():
|
|
"""Direct import when possible, else AST extraction from vision.py."""
|
|
try:
|
|
from unsloth.models.vision import check_dataset_for_missing_videos as fn
|
|
return fn
|
|
except Exception:
|
|
pass
|
|
|
|
vision_path = Path(__file__).parent.parent / "unsloth" / "models" / "vision.py"
|
|
fns = _extract_fns_via_ast(
|
|
vision_path,
|
|
[
|
|
"_looks_like_message_list",
|
|
"_iter_message_lists",
|
|
"_local_path_from_video_value",
|
|
"check_dataset_for_missing_videos",
|
|
],
|
|
)
|
|
return fns["check_dataset_for_missing_videos"]
|
|
|
|
|
|
@pytest.fixture(scope = "session")
|
|
def make_auto_validating_collator(check_dataset_for_missing_videos):
|
|
"""Factory for a minimal collator mirroring the trainer.py wrapper."""
|
|
|
|
class _FakeBase:
|
|
def __init__(self, formatting_func = None):
|
|
self.formatting_func = formatting_func
|
|
|
|
def __call__(self, examples):
|
|
if self.formatting_func is not None:
|
|
examples = [self.formatting_func(e) for e in examples]
|
|
return {"ok": True, "examples": examples}
|
|
|
|
class _AutoValidatingCollator(_FakeBase):
|
|
def __init__(self, formatting_func = None):
|
|
super().__init__(formatting_func = formatting_func)
|
|
self._checked_video_paths = set()
|
|
|
|
def __call__(self, examples):
|
|
formatting_func = self.formatting_func
|
|
if formatting_func is not None:
|
|
examples = [formatting_func(e) for e in examples]
|
|
check_dataset_for_missing_videos(
|
|
examples,
|
|
raise_error = True,
|
|
checked = self._checked_video_paths,
|
|
)
|
|
if formatting_func is None:
|
|
return super().__call__(examples)
|
|
self.formatting_func = None
|
|
try:
|
|
return super().__call__(examples)
|
|
finally:
|
|
self.formatting_func = formatting_func
|
|
|
|
return _AutoValidatingCollator
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_video_dataset(*video_paths):
|
|
return [
|
|
{"messages": [{"role": "user", "content": [{"type": "video", "video": p}]}]}
|
|
for p in video_paths
|
|
]
|
|
|
|
|
|
def _batch(*video_paths):
|
|
return _make_video_dataset(*video_paths)
|
|
|
|
|
|
# ── Tests: check_dataset_for_missing_videos ───────────────────────────────────
|
|
|
|
|
|
def test_missing_local_file_raises(check_dataset_for_missing_videos):
|
|
"""Missing local path raises FileNotFoundError."""
|
|
ds = _make_video_dataset("/nonexistent/videos/clip.mp4")
|
|
with pytest.raises(FileNotFoundError):
|
|
check_dataset_for_missing_videos(ds)
|
|
|
|
|
|
def test_remote_url_skipped(check_dataset_for_missing_videos):
|
|
"""http/https URLs are not checked locally."""
|
|
ds = _make_video_dataset("https://example.com/video.mp4")
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_existing_file_accepted(check_dataset_for_missing_videos):
|
|
"""Existing local file passes without error."""
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"fake video bytes")
|
|
tmp = f.name
|
|
try:
|
|
ds = _make_video_dataset(tmp)
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_file_uri_scheme_stripped(check_dataset_for_missing_videos):
|
|
"""file:// scheme is stripped before the path check."""
|
|
ds = _make_video_dataset("file:///nonexistent/clip.mp4")
|
|
with pytest.raises(FileNotFoundError):
|
|
check_dataset_for_missing_videos(ds)
|
|
|
|
|
|
def test_warn_only_mode(check_dataset_for_missing_videos):
|
|
"""raise_error=False warns and returns the missing paths."""
|
|
ds = _make_video_dataset("/nonexistent/videos/clip.mp4")
|
|
with warnings.catch_warnings(record = True) as caught:
|
|
warnings.simplefilter("always")
|
|
missing = check_dataset_for_missing_videos(ds, raise_error = False)
|
|
|
|
assert len(caught) == 1
|
|
assert "could not be found" in str(caught[0].message)
|
|
assert missing == ["/nonexistent/videos/clip.mp4"]
|
|
|
|
|
|
def test_duplicate_paths_deduplicated(check_dataset_for_missing_videos):
|
|
"""Repeated missing path is listed once."""
|
|
ds = _make_video_dataset("/nonexistent/clip.mp4", "/nonexistent/clip.mp4")
|
|
with pytest.raises(FileNotFoundError) as exc_info:
|
|
check_dataset_for_missing_videos(ds)
|
|
assert str(exc_info.value).count("/nonexistent/clip.mp4") == 1
|
|
|
|
|
|
# ── Tests: UnslothVisionDataCollator auto-validation ─────────────────────────
|
|
|
|
|
|
def test_collator_raises_on_first_batch_with_missing_video(make_auto_validating_collator):
|
|
"""Collator raises on a missing path with no user action needed."""
|
|
collator = make_auto_validating_collator()
|
|
batch = _batch("/nonexistent/auto/clip.mp4")
|
|
with pytest.raises(FileNotFoundError):
|
|
collator(batch)
|
|
|
|
|
|
def test_collator_passes_on_first_batch_with_valid_video(make_auto_validating_collator):
|
|
"""Collator passes a valid batch through."""
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"fake video bytes")
|
|
tmp = f.name
|
|
try:
|
|
collator = make_auto_validating_collator()
|
|
batch = _batch(tmp)
|
|
result = collator(batch)
|
|
assert result["ok"] is True
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_collator_validates_every_batch(make_auto_validating_collator):
|
|
"""A missing video first appearing after batch 0 must still raise."""
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"fake video bytes")
|
|
tmp = f.name
|
|
try:
|
|
collator = make_auto_validating_collator()
|
|
collator(_batch(tmp)) # batch 0: valid
|
|
with pytest.raises(FileNotFoundError):
|
|
collator(_batch("/nonexistent/late.mp4"))
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_collator_dedupes_across_batches(make_auto_validating_collator):
|
|
"""The checked-path set is shared across batches."""
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"fake video bytes")
|
|
tmp = f.name
|
|
try:
|
|
collator = make_auto_validating_collator()
|
|
collator(_batch(tmp))
|
|
collator(_batch(tmp, tmp))
|
|
assert tmp in collator._checked_video_paths
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_conversations_column_missing_detected(check_dataset_for_missing_videos):
|
|
"""'conversations' column is scanned."""
|
|
ds = [
|
|
{
|
|
"conversations": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": "/nonexistent/conv.mp4"}],
|
|
}
|
|
]
|
|
},
|
|
]
|
|
with pytest.raises(FileNotFoundError):
|
|
check_dataset_for_missing_videos(ds)
|
|
|
|
|
|
def test_prompt_completion_column_missing_detected(check_dataset_for_missing_videos):
|
|
"""'prompt'/'completion' columns are scanned."""
|
|
ds = [
|
|
{
|
|
"prompt": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": "/nonexistent/p.mp4"}],
|
|
}
|
|
],
|
|
"completion": [{"role": "assistant", "content": [{"type": "text", "text": "hi"}]}],
|
|
},
|
|
]
|
|
with pytest.raises(FileNotFoundError) as exc_info:
|
|
check_dataset_for_missing_videos(ds)
|
|
assert "/nonexistent/p.mp4" in str(exc_info.value)
|
|
|
|
|
|
def test_raw_message_list_example_missing_detected(check_dataset_for_missing_videos):
|
|
"""Rows that are themselves message lists (no outer dict) are scanned."""
|
|
ds = [
|
|
[
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": "/nonexistent/raw.mp4"}],
|
|
}
|
|
],
|
|
]
|
|
with pytest.raises(FileNotFoundError):
|
|
check_dataset_for_missing_videos(ds)
|
|
|
|
|
|
def test_non_dict_message_entry_does_not_crash(check_dataset_for_missing_videos):
|
|
"""Non-dict message entries are skipped."""
|
|
ds = [{"messages": ["not a dict", {"role": "user", "content": []}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_file_uri_percent_encoded(check_dataset_for_missing_videos, tmp_path):
|
|
"""Percent-encoded file:// URIs decode to the real path."""
|
|
target = tmp_path / "my video.mp4"
|
|
target.write_bytes(b"x")
|
|
uri = "file://" + str(target).replace(" ", "%20")
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": uri}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_file_uri_localhost_host(check_dataset_for_missing_videos, tmp_path):
|
|
"""file://localhost/<abs path> is the local machine (RFC 8089)."""
|
|
target = tmp_path / "clip.mp4"
|
|
target.write_bytes(b"x")
|
|
uri = f"file://localhost{target}"
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": uri}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_checked_set_reused_across_calls(check_dataset_for_missing_videos, tmp_path):
|
|
"""A supplied checked set is populated and deduped across calls."""
|
|
target = tmp_path / "clip.mp4"
|
|
target.write_bytes(b"x")
|
|
shared = set()
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": str(target)}]}]}]
|
|
check_dataset_for_missing_videos(ds, checked = shared)
|
|
assert str(target) in shared
|
|
check_dataset_for_missing_videos(ds, checked = shared)
|
|
assert len(shared) == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"uri",
|
|
[
|
|
"s3://bucket/clip.mp4",
|
|
"gs://bucket/clip.mp4",
|
|
"hf://datasets/u/r/clip.mp4",
|
|
"ftp://host/clip.mp4",
|
|
"az://container/clip.mp4",
|
|
],
|
|
)
|
|
def test_non_file_remote_scheme_skipped(check_dataset_for_missing_videos, uri):
|
|
"""Non-file URI schemes are treated as remote and skipped."""
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": uri}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_file_uri_non_localhost_host_skipped(check_dataset_for_missing_videos):
|
|
"""file://<non-localhost>/path is remote (RFC 8089): skip local checks."""
|
|
ds = [
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": "file://nas-server/share/clip.mp4"}],
|
|
}
|
|
]
|
|
}
|
|
]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
@pytest.mark.parametrize("uri", ["file://", "file://hostname"])
|
|
def test_degenerate_file_uri_skipped(check_dataset_for_missing_videos, uri):
|
|
"""No path component must not produce a blank missing entry."""
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": uri}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_file_uri_double_encoded_percent(check_dataset_for_missing_videos, tmp_path):
|
|
"""%2520 must single-unquote to 'clip%20.mp4', not 'clip .mp4'."""
|
|
target = tmp_path / "clip%20.mp4"
|
|
target.write_bytes(b"x")
|
|
uri = "file://" + str(target).replace("%", "%25")
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": uri}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_windows_style_absolute_path_not_mistaken_for_scheme(
|
|
check_dataset_for_missing_videos, tmp_path
|
|
):
|
|
"""'C:/...' has no '://' so it is a plain path, even where urlparse
|
|
would yield scheme='c'."""
|
|
target = tmp_path / "clip.mp4"
|
|
target.write_bytes(b"x")
|
|
path = str(target)
|
|
if os.name != "nt":
|
|
# '://'-free values must round-trip unchanged; keep the real path
|
|
path = str(target)
|
|
ds = [{"messages": [{"role": "user", "content": [{"type": "video", "video": path}]}]}]
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
ds_missing = [
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": "C:/definitely/missing.mp4"}],
|
|
}
|
|
]
|
|
}
|
|
]
|
|
with pytest.raises(FileNotFoundError) as exc:
|
|
check_dataset_for_missing_videos(ds_missing)
|
|
assert "C:/definitely/missing.mp4" in str(exc.value)
|
|
|
|
|
|
def test_iterable_dataset_warns_and_skips(check_dataset_for_missing_videos):
|
|
"""Streaming IterableDataset: warn, return [], do not exhaust it."""
|
|
datasets_mod = pytest.importorskip("datasets", reason = "real datasets package required")
|
|
if not hasattr(datasets_mod, "IterableDataset"):
|
|
pytest.skip("datasets.IterableDataset not available in this environment")
|
|
IterableDataset = datasets_mod.IterableDataset
|
|
|
|
def gen():
|
|
for p in ("/nonexistent/a.mp4", "/nonexistent/b.mp4"):
|
|
yield {"messages": [{"role": "user", "content": [{"type": "video", "video": p}]}]}
|
|
|
|
ds = IterableDataset.from_generator(gen)
|
|
with warnings.catch_warnings(record = True) as caught:
|
|
warnings.simplefilter("always")
|
|
result = check_dataset_for_missing_videos(ds)
|
|
assert result == []
|
|
assert any("IterableDataset" in str(w.message) for w in caught)
|
|
# generator must not have been exhausted
|
|
consumed = list(ds)
|
|
assert len(consumed) == 2
|
|
|
|
|
|
def test_collator_applies_formatting_func_before_validation(make_auto_validating_collator):
|
|
"""formatting_func runs before validation; super gets formatted examples
|
|
and must not re-apply it."""
|
|
|
|
def fmt(example):
|
|
return {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": example["video_id"]}],
|
|
}
|
|
]
|
|
}
|
|
|
|
raise_collator = make_auto_validating_collator(formatting_func = fmt)
|
|
with pytest.raises(FileNotFoundError):
|
|
raise_collator([{"video_id": "/nonexistent/formatted.mp4"}])
|
|
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"x")
|
|
tmp = f.name
|
|
try:
|
|
ok_collator = make_auto_validating_collator(formatting_func = fmt)
|
|
before = ok_collator.formatting_func
|
|
result = ok_collator([{"video_id": tmp}])
|
|
assert result["ok"] is True
|
|
assert ok_collator.formatting_func is before
|
|
passed = result["examples"]
|
|
assert passed[0]["messages"][0]["content"][0]["video"] == tmp
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_data_uri_skipped(check_dataset_for_missing_videos):
|
|
"""Inline data: URIs are not flagged missing."""
|
|
ds = _make_video_dataset("data:video/mp4;base64,AAAABBBBCCCC")
|
|
assert check_dataset_for_missing_videos(ds) == []
|
|
|
|
|
|
def test_tuple_content_entries_checked(check_dataset_for_missing_videos):
|
|
"""Tuple message content is validated like a list."""
|
|
ds = [
|
|
{
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": ({"type": "video", "video": "/nonexistent/tuple.mp4"},),
|
|
}
|
|
]
|
|
}
|
|
]
|
|
with pytest.raises(FileNotFoundError):
|
|
check_dataset_for_missing_videos(ds)
|
|
|
|
|
|
def test_duplicate_missing_deduped_in_warn_mode(check_dataset_for_missing_videos):
|
|
"""Warn mode returns each missing path once."""
|
|
ds = _make_video_dataset("/nonexistent/dup.mp4", "/nonexistent/dup.mp4")
|
|
with warnings.catch_warnings(record = True):
|
|
warnings.simplefilter("always")
|
|
missing = check_dataset_for_missing_videos(ds, raise_error = False)
|
|
assert missing == ["/nonexistent/dup.mp4"]
|
|
|
|
|
|
# ── Tests: real unsloth_zoo collator integration ─────────────────────────────
|
|
# Exercise the real trainer.py subclass against the real zoo base (the fakes
|
|
# above don't cover super()/formatting_func); skip when unsloth can't import.
|
|
|
|
|
|
@pytest.fixture(scope = "session")
|
|
def real_collator_classes():
|
|
try:
|
|
from unsloth.trainer import UnslothVisionDataCollator
|
|
from unsloth_zoo.vision_utils import (
|
|
UnslothVisionDataCollator as ZooBase,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 - skip on any import failure
|
|
pytest.skip(f"full unsloth import unavailable: {exc!r}")
|
|
return UnslothVisionDataCollator, ZooBase
|
|
|
|
|
|
def _make_real_collator(real_collator_classes, formatting_func = None):
|
|
"""Build the real subclass without its heavy __init__ (needs a processor)."""
|
|
subclass, _ = real_collator_classes
|
|
collator = subclass.__new__(subclass)
|
|
collator.formatting_func = formatting_func
|
|
collator._checked_video_paths = set()
|
|
return collator
|
|
|
|
|
|
def test_real_collator_blocks_super_on_missing_video(real_collator_classes, monkeypatch):
|
|
"""Missing path raises before the base __call__ runs."""
|
|
_, zoo_base = real_collator_classes
|
|
calls = []
|
|
monkeypatch.setattr(zoo_base, "__call__", lambda self, examples: calls.append(examples))
|
|
collator = _make_real_collator(real_collator_classes)
|
|
with pytest.raises(FileNotFoundError):
|
|
collator(_batch("/nonexistent/real.mp4"))
|
|
assert calls == [] # base collator was never reached
|
|
|
|
|
|
def test_real_collator_calls_super_with_formatting_disabled(real_collator_classes, monkeypatch):
|
|
"""Base must see formatting_func=None and already-formatted examples;
|
|
the original formatting_func is restored afterwards."""
|
|
seen = {}
|
|
|
|
def spy(self, examples):
|
|
seen["formatting_func"] = self.formatting_func
|
|
seen["examples"] = examples
|
|
return {"ok": True}
|
|
|
|
_, zoo_base = real_collator_classes
|
|
monkeypatch.setattr(zoo_base, "__call__", spy)
|
|
|
|
def fmt(example):
|
|
return {
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [{"type": "video", "video": example["video_id"]}],
|
|
}
|
|
]
|
|
}
|
|
|
|
with tempfile.NamedTemporaryFile(suffix = ".mp4", delete = False) as f:
|
|
f.write(b"x")
|
|
tmp = f.name
|
|
try:
|
|
collator = _make_real_collator(real_collator_classes, formatting_func = fmt)
|
|
result = collator([{"video_id": tmp}])
|
|
assert result == {"ok": True}
|
|
assert seen["formatting_func"] is None
|
|
assert seen["examples"][0]["messages"][0]["content"][0]["video"] == tmp
|
|
assert collator.formatting_func is fmt
|
|
assert tmp in collator._checked_video_paths
|
|
finally:
|
|
os.unlink(tmp)
|
|
|
|
|
|
def test_real_collator_restores_formatting_func_when_super_raises(
|
|
real_collator_classes, monkeypatch
|
|
):
|
|
"""formatting_func is restored even when the base raises."""
|
|
|
|
def boom(self, examples):
|
|
raise RuntimeError("base collator failed")
|
|
|
|
_, zoo_base = real_collator_classes
|
|
monkeypatch.setattr(zoo_base, "__call__", boom)
|
|
|
|
def fmt(example):
|
|
return {"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}
|
|
|
|
collator = _make_real_collator(real_collator_classes, formatting_func = fmt)
|
|
with pytest.raises(RuntimeError):
|
|
collator([{"anything": 1}])
|
|
assert collator.formatting_func is fmt
|