feat: add skill-seekers video --setup for GPU auto-detection and dependency installation
Auto-detects NVIDIA (CUDA), AMD (ROCm), or CPU-only GPU and installs the correct PyTorch variant + easyocr + all visual extraction dependencies. Removes easyocr from video-full pip extras to avoid pulling ~2GB of wrong CUDA packages on non-NVIDIA systems. New files: - video_setup.py (835 lines): GPU detection, PyTorch install, ROCm config, venv checks, system dep validation, module selection, verification - test_video_setup.py (60 tests): Full coverage of detection, install, verify Updated docs: CHANGELOG, AGENTS.md, CLAUDE.md, README.md, CLI_REFERENCE, FAQ, TROUBLESHOOTING, installation guide, video dependency plan All 2523 tests passing (15 skipped). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3115,5 +3115,286 @@ class TestVideoWorkflowAutoInjection(unittest.TestCase):
|
||||
self.assertTrue(yaml_path.exists(), "video-tutorial.yaml not found")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: Time Clipping (--start-time / --end-time)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTimeClipping(unittest.TestCase):
|
||||
"""Test --start-time / --end-time clipping support."""
|
||||
|
||||
# ---- parse_time_to_seconds() ----
|
||||
|
||||
def test_parse_time_seconds_integer(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertEqual(parse_time_to_seconds("330"), 330.0)
|
||||
|
||||
def test_parse_time_seconds_float(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertAlmostEqual(parse_time_to_seconds("90.5"), 90.5)
|
||||
|
||||
def test_parse_time_mmss(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertEqual(parse_time_to_seconds("5:30"), 330.0)
|
||||
|
||||
def test_parse_time_hhmmss(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertEqual(parse_time_to_seconds("1:05:30"), 3930.0)
|
||||
|
||||
def test_parse_time_zero(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertEqual(parse_time_to_seconds("0"), 0.0)
|
||||
self.assertEqual(parse_time_to_seconds("0:00"), 0.0)
|
||||
self.assertEqual(parse_time_to_seconds("0:00:00"), 0.0)
|
||||
|
||||
def test_parse_time_decimal_mmss(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
self.assertAlmostEqual(parse_time_to_seconds("1:30.5"), 90.5)
|
||||
|
||||
def test_parse_time_invalid_raises(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_time_to_seconds("abc")
|
||||
|
||||
def test_parse_time_empty_raises(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_time_to_seconds("")
|
||||
|
||||
def test_parse_time_too_many_colons_raises(self):
|
||||
from skill_seekers.cli.video_scraper import parse_time_to_seconds
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
parse_time_to_seconds("1:2:3:4")
|
||||
|
||||
# ---- Argument registration ----
|
||||
|
||||
def test_video_arguments_include_start_end_time(self):
|
||||
from skill_seekers.cli.arguments.video import VIDEO_ARGUMENTS
|
||||
|
||||
self.assertIn("start_time", VIDEO_ARGUMENTS)
|
||||
self.assertIn("end_time", VIDEO_ARGUMENTS)
|
||||
|
||||
def test_create_arguments_include_start_end_time(self):
|
||||
from skill_seekers.cli.arguments.create import VIDEO_ARGUMENTS
|
||||
|
||||
self.assertIn("start_time", VIDEO_ARGUMENTS)
|
||||
self.assertIn("end_time", VIDEO_ARGUMENTS)
|
||||
|
||||
def test_argument_parsing_defaults_none(self):
|
||||
import argparse
|
||||
from skill_seekers.cli.arguments.video import add_video_arguments
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
add_video_arguments(parser)
|
||||
args = parser.parse_args(["--url", "https://example.com"])
|
||||
self.assertIsNone(args.start_time)
|
||||
self.assertIsNone(args.end_time)
|
||||
|
||||
def test_argument_parsing_with_values(self):
|
||||
import argparse
|
||||
from skill_seekers.cli.arguments.video import add_video_arguments
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
add_video_arguments(parser)
|
||||
args = parser.parse_args(
|
||||
["--url", "https://example.com", "--start-time", "2:00", "--end-time", "5:00"]
|
||||
)
|
||||
self.assertEqual(args.start_time, "2:00")
|
||||
self.assertEqual(args.end_time, "5:00")
|
||||
|
||||
# ---- Transcript filtering ----
|
||||
|
||||
def test_transcript_clip_filters_segments(self):
|
||||
"""Verify transcript segments are filtered to clip range."""
|
||||
from skill_seekers.cli.video_models import TranscriptSegment
|
||||
|
||||
segments = [
|
||||
TranscriptSegment(text="intro", start=0.0, end=30.0),
|
||||
TranscriptSegment(text="part1", start=30.0, end=90.0),
|
||||
TranscriptSegment(text="part2", start=90.0, end=150.0),
|
||||
TranscriptSegment(text="outro", start=150.0, end=200.0),
|
||||
]
|
||||
|
||||
clip_start, clip_end = 60.0, 120.0
|
||||
filtered = [s for s in segments if s.end > clip_start and s.start < clip_end]
|
||||
# part1 (30-90) overlaps with 60-120, part2 (90-150) overlaps with 60-120
|
||||
self.assertEqual(len(filtered), 2)
|
||||
self.assertEqual(filtered[0].text, "part1")
|
||||
self.assertEqual(filtered[1].text, "part2")
|
||||
|
||||
def test_transcript_clip_start_only(self):
|
||||
"""Verify only clip_start filters correctly."""
|
||||
from skill_seekers.cli.video_models import TranscriptSegment
|
||||
|
||||
segments = [
|
||||
TranscriptSegment(text="before", start=0.0, end=50.0),
|
||||
TranscriptSegment(text="after", start=50.0, end=100.0),
|
||||
]
|
||||
clip_start = 50.0
|
||||
clip_end = float("inf")
|
||||
filtered = [s for s in segments if s.end > clip_start and s.start < clip_end]
|
||||
self.assertEqual(len(filtered), 1)
|
||||
self.assertEqual(filtered[0].text, "after")
|
||||
|
||||
# ---- Validation ----
|
||||
|
||||
def test_playlist_plus_clip_rejected(self):
|
||||
from skill_seekers.cli.video_models import VideoSourceConfig
|
||||
|
||||
config = VideoSourceConfig(
|
||||
playlist="https://youtube.com/playlist?list=x",
|
||||
clip_start=60.0,
|
||||
)
|
||||
errors = config.validate()
|
||||
self.assertTrue(any("--start-time" in e for e in errors))
|
||||
|
||||
def test_start_gte_end_rejected(self):
|
||||
from skill_seekers.cli.video_models import VideoSourceConfig
|
||||
|
||||
config = VideoSourceConfig(
|
||||
url="https://youtube.com/watch?v=x", clip_start=300.0, clip_end=120.0
|
||||
)
|
||||
errors = config.validate()
|
||||
self.assertTrue(any("must be before" in e for e in errors))
|
||||
|
||||
def test_valid_clip_no_errors(self):
|
||||
from skill_seekers.cli.video_models import VideoSourceConfig
|
||||
|
||||
config = VideoSourceConfig(
|
||||
url="https://youtube.com/watch?v=x", clip_start=60.0, clip_end=300.0
|
||||
)
|
||||
errors = config.validate()
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
# ---- VideoInfo clip metadata serialization ----
|
||||
|
||||
def test_video_info_clip_roundtrip(self):
|
||||
from skill_seekers.cli.video_models import VideoInfo, VideoSourceType
|
||||
|
||||
info = VideoInfo(
|
||||
video_id="test",
|
||||
source_type=VideoSourceType.YOUTUBE,
|
||||
duration=300.0,
|
||||
original_duration=600.0,
|
||||
clip_start=120.0,
|
||||
clip_end=420.0,
|
||||
)
|
||||
data = info.to_dict()
|
||||
self.assertEqual(data["original_duration"], 600.0)
|
||||
self.assertEqual(data["clip_start"], 120.0)
|
||||
self.assertEqual(data["clip_end"], 420.0)
|
||||
|
||||
restored = VideoInfo.from_dict(data)
|
||||
self.assertEqual(restored.original_duration, 600.0)
|
||||
self.assertEqual(restored.clip_start, 120.0)
|
||||
self.assertEqual(restored.clip_end, 420.0)
|
||||
|
||||
def test_video_info_no_clip_roundtrip(self):
|
||||
from skill_seekers.cli.video_models import VideoInfo, VideoSourceType
|
||||
|
||||
info = VideoInfo(video_id="test", source_type=VideoSourceType.YOUTUBE)
|
||||
data = info.to_dict()
|
||||
self.assertIsNone(data["original_duration"])
|
||||
self.assertIsNone(data["clip_start"])
|
||||
self.assertIsNone(data["clip_end"])
|
||||
|
||||
restored = VideoInfo.from_dict(data)
|
||||
self.assertIsNone(restored.original_duration)
|
||||
self.assertIsNone(restored.clip_start)
|
||||
|
||||
# ---- VideoSourceConfig clip fields ----
|
||||
|
||||
def test_source_config_clip_fields(self):
|
||||
from skill_seekers.cli.video_models import VideoSourceConfig
|
||||
|
||||
config = VideoSourceConfig.from_dict(
|
||||
{
|
||||
"url": "https://example.com",
|
||||
"clip_start": 10.0,
|
||||
"clip_end": 60.0,
|
||||
}
|
||||
)
|
||||
self.assertEqual(config.clip_start, 10.0)
|
||||
self.assertEqual(config.clip_end, 60.0)
|
||||
|
||||
def test_source_config_clip_defaults_none(self):
|
||||
from skill_seekers.cli.video_models import VideoSourceConfig
|
||||
|
||||
config = VideoSourceConfig.from_dict({"url": "https://example.com"})
|
||||
self.assertIsNone(config.clip_start)
|
||||
self.assertIsNone(config.clip_end)
|
||||
|
||||
# ---- Converter init ----
|
||||
|
||||
def test_converter_init_with_clip_times(self):
|
||||
from skill_seekers.cli.video_scraper import VideoToSkillConverter
|
||||
|
||||
config = {
|
||||
"name": "test",
|
||||
"url": "https://youtube.com/watch?v=x",
|
||||
"start_time": 120.0,
|
||||
"end_time": 300.0,
|
||||
}
|
||||
converter = VideoToSkillConverter(config)
|
||||
self.assertEqual(converter.start_time, 120.0)
|
||||
self.assertEqual(converter.end_time, 300.0)
|
||||
|
||||
def test_converter_init_without_clip_times(self):
|
||||
from skill_seekers.cli.video_scraper import VideoToSkillConverter
|
||||
|
||||
config = {"name": "test", "url": "https://youtube.com/watch?v=x"}
|
||||
converter = VideoToSkillConverter(config)
|
||||
self.assertIsNone(converter.start_time)
|
||||
self.assertIsNone(converter.end_time)
|
||||
|
||||
# ---- Segmenter start_offset / end_limit ----
|
||||
|
||||
def test_segmenter_time_window_with_offset(self):
|
||||
from skill_seekers.cli.video_segmenter import segment_by_time_window
|
||||
from skill_seekers.cli.video_models import VideoInfo, VideoSourceType
|
||||
|
||||
info = VideoInfo(video_id="test", source_type=VideoSourceType.YOUTUBE, duration=600.0)
|
||||
# Use 120s windows starting at 120s, ending at 360s
|
||||
segments = segment_by_time_window(
|
||||
info, [], window_seconds=120.0, start_offset=120.0, end_limit=360.0
|
||||
)
|
||||
# No transcript segments so no segments generated, but verify no crash
|
||||
self.assertEqual(len(segments), 0)
|
||||
|
||||
def test_segmenter_time_window_offset_with_transcript(self):
|
||||
from skill_seekers.cli.video_segmenter import segment_by_time_window
|
||||
from skill_seekers.cli.video_models import (
|
||||
VideoInfo,
|
||||
VideoSourceType,
|
||||
TranscriptSegment,
|
||||
)
|
||||
|
||||
info = VideoInfo(video_id="test", source_type=VideoSourceType.YOUTUBE, duration=600.0)
|
||||
transcript = [
|
||||
TranscriptSegment(text="before clip", start=0.0, end=60.0),
|
||||
TranscriptSegment(text="in clip part1", start=120.0, end=180.0),
|
||||
TranscriptSegment(text="in clip part2", start=200.0, end=300.0),
|
||||
TranscriptSegment(text="after clip", start=400.0, end=500.0),
|
||||
]
|
||||
segments = segment_by_time_window(
|
||||
info, transcript, window_seconds=120.0, start_offset=120.0, end_limit=360.0
|
||||
)
|
||||
# Should have segments starting at 120, 240
|
||||
self.assertTrue(len(segments) >= 1)
|
||||
# All segments should be within clip range
|
||||
for seg in segments:
|
||||
self.assertGreaterEqual(seg.start_time, 120.0)
|
||||
self.assertLessEqual(seg.end_time, 360.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
679
tests/test_video_setup.py
Normal file
679
tests/test_video_setup.py
Normal file
@@ -0,0 +1,679 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Video Setup (cli/video_setup.py) and video_visual.py resilience.
|
||||
|
||||
Tests cover:
|
||||
- GPU detection (NVIDIA, AMD ROCm, AMD without ROCm, CPU fallback)
|
||||
- CUDA / ROCm version → index URL mapping
|
||||
- PyTorch installation (mocked subprocess)
|
||||
- Visual deps installation (mocked subprocess)
|
||||
- Installation verification
|
||||
- run_setup orchestrator
|
||||
- Venv detection and creation
|
||||
- System dep checks (tesseract binary)
|
||||
- ROCm env var configuration
|
||||
- Module selection (SetupModules)
|
||||
- Tesseract circuit breaker (video_visual.py)
|
||||
- --setup flag in VIDEO_ARGUMENTS and early-exit in video_scraper
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from skill_seekers.cli.video_setup import (
|
||||
_BASE_VIDEO_DEPS,
|
||||
GPUInfo,
|
||||
GPUVendor,
|
||||
SetupModules,
|
||||
_build_visual_deps,
|
||||
_cuda_version_to_index_url,
|
||||
_detect_distro,
|
||||
_PYTORCH_BASE,
|
||||
_rocm_version_to_index_url,
|
||||
check_tesseract,
|
||||
configure_rocm_env,
|
||||
create_venv,
|
||||
detect_gpu,
|
||||
get_venv_activate_cmd,
|
||||
get_venv_python,
|
||||
install_torch,
|
||||
install_visual_deps,
|
||||
is_in_venv,
|
||||
run_setup,
|
||||
verify_installation,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GPU Detection Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestGPUDetection(unittest.TestCase):
|
||||
"""Tests for detect_gpu() and its helpers."""
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_nvidia_detected(self, mock_run, mock_which):
|
||||
"""nvidia-smi present → GPUVendor.NVIDIA."""
|
||||
mock_which.side_effect = lambda cmd: "/usr/bin/nvidia-smi" if cmd == "nvidia-smi" else None
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout=(
|
||||
"+-------------------------+\n"
|
||||
"| NVIDIA GeForce RTX 4090 On |\n"
|
||||
"| CUDA Version: 12.4 |\n"
|
||||
"+-------------------------+\n"
|
||||
),
|
||||
)
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.NVIDIA
|
||||
assert "12.4" in gpu.compute_version
|
||||
assert "cu124" in gpu.index_url
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
@patch("skill_seekers.cli.video_setup._read_rocm_version", return_value="6.3.1")
|
||||
def test_amd_rocm_detected(self, mock_rocm_ver, mock_run, mock_which):
|
||||
"""rocminfo present → GPUVendor.AMD."""
|
||||
|
||||
def which_side(cmd):
|
||||
if cmd == "nvidia-smi":
|
||||
return None
|
||||
if cmd == "rocminfo":
|
||||
return "/usr/bin/rocminfo"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="Marketing Name: AMD Radeon RX 7900 XTX\n",
|
||||
)
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.AMD
|
||||
assert "rocm6.3" in gpu.index_url
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_amd_no_rocm_fallback(self, mock_run, mock_which):
|
||||
"""AMD GPU in lspci but no ROCm → AMD vendor, CPU index URL."""
|
||||
|
||||
def which_side(cmd):
|
||||
if cmd == "lspci":
|
||||
return "/usr/bin/lspci"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side
|
||||
|
||||
mock_run.return_value = MagicMock(
|
||||
returncode=0,
|
||||
stdout="06:00.0 VGA compatible controller: AMD/ATI Navi 31 [Radeon RX 7900 XTX]\n",
|
||||
)
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.AMD
|
||||
assert "cpu" in gpu.index_url
|
||||
assert any("ROCm is not installed" in d for d in gpu.details)
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which", return_value=None)
|
||||
def test_cpu_fallback(self, mock_which):
|
||||
"""No GPU tools found → GPUVendor.NONE."""
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.NONE
|
||||
assert "cpu" in gpu.index_url
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_nvidia_smi_error(self, mock_run, mock_which):
|
||||
"""nvidia-smi returns non-zero → skip to next check."""
|
||||
mock_which.side_effect = lambda cmd: (
|
||||
"/usr/bin/nvidia-smi" if cmd == "nvidia-smi" else None
|
||||
)
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.NONE
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_nvidia_smi_timeout(self, mock_run, mock_which):
|
||||
"""nvidia-smi times out → skip to next check."""
|
||||
mock_which.side_effect = lambda cmd: (
|
||||
"/usr/bin/nvidia-smi" if cmd == "nvidia-smi" else None
|
||||
)
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="nvidia-smi", timeout=10)
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.NONE
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which")
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_rocminfo_error(self, mock_run, mock_which):
|
||||
"""rocminfo returns non-zero → skip to next check."""
|
||||
|
||||
def which_side(cmd):
|
||||
if cmd == "nvidia-smi":
|
||||
return None
|
||||
if cmd == "rocminfo":
|
||||
return "/usr/bin/rocminfo"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
gpu = detect_gpu()
|
||||
assert gpu.vendor == GPUVendor.NONE
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Version Mapping Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestVersionMapping(unittest.TestCase):
|
||||
"""Tests for CUDA/ROCm version → index URL mapping."""
|
||||
|
||||
def test_cuda_124(self):
|
||||
assert _cuda_version_to_index_url("12.4") == f"{_PYTORCH_BASE}/cu124"
|
||||
|
||||
def test_cuda_126(self):
|
||||
assert _cuda_version_to_index_url("12.6") == f"{_PYTORCH_BASE}/cu124"
|
||||
|
||||
def test_cuda_121(self):
|
||||
assert _cuda_version_to_index_url("12.1") == f"{_PYTORCH_BASE}/cu121"
|
||||
|
||||
def test_cuda_118(self):
|
||||
assert _cuda_version_to_index_url("11.8") == f"{_PYTORCH_BASE}/cu118"
|
||||
|
||||
def test_cuda_old_falls_to_cpu(self):
|
||||
assert _cuda_version_to_index_url("10.2") == f"{_PYTORCH_BASE}/cpu"
|
||||
|
||||
def test_cuda_invalid_string(self):
|
||||
assert _cuda_version_to_index_url("garbage") == f"{_PYTORCH_BASE}/cpu"
|
||||
|
||||
def test_rocm_63(self):
|
||||
assert _rocm_version_to_index_url("6.3.1") == f"{_PYTORCH_BASE}/rocm6.3"
|
||||
|
||||
def test_rocm_60(self):
|
||||
assert _rocm_version_to_index_url("6.0") == f"{_PYTORCH_BASE}/rocm6.2.4"
|
||||
|
||||
def test_rocm_old_falls_to_cpu(self):
|
||||
assert _rocm_version_to_index_url("5.4") == f"{_PYTORCH_BASE}/cpu"
|
||||
|
||||
def test_rocm_invalid(self):
|
||||
assert _rocm_version_to_index_url("bad") == f"{_PYTORCH_BASE}/cpu"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Venv Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestVenv(unittest.TestCase):
|
||||
"""Tests for venv detection and creation."""
|
||||
|
||||
def test_is_in_venv_returns_bool(self):
|
||||
result = is_in_venv()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_is_in_venv_detects_prefix_mismatch(self):
|
||||
# If sys.prefix != sys.base_prefix, we're in a venv
|
||||
with patch.object(sys, "prefix", "/some/venv"), \
|
||||
patch.object(sys, "base_prefix", "/usr"):
|
||||
assert is_in_venv() is True
|
||||
|
||||
def test_is_in_venv_detects_no_venv(self):
|
||||
with patch.object(sys, "prefix", "/usr"), \
|
||||
patch.object(sys, "base_prefix", "/usr"):
|
||||
assert is_in_venv() is False
|
||||
|
||||
def test_create_venv_in_tempdir(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
venv_path = os.path.join(tmpdir, "test_venv")
|
||||
result = create_venv(venv_path)
|
||||
assert result is True
|
||||
assert os.path.isdir(venv_path)
|
||||
|
||||
def test_create_venv_already_exists(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create it once
|
||||
create_venv(tmpdir)
|
||||
# Creating again should succeed (already exists)
|
||||
assert create_venv(tmpdir) is True
|
||||
|
||||
def test_get_venv_python_linux(self):
|
||||
with patch("skill_seekers.cli.video_setup.platform.system", return_value="Linux"):
|
||||
path = get_venv_python("/path/.venv")
|
||||
assert path.endswith("bin/python")
|
||||
|
||||
def test_get_venv_activate_cmd_linux(self):
|
||||
with patch("skill_seekers.cli.video_setup.platform.system", return_value="Linux"):
|
||||
cmd = get_venv_activate_cmd("/path/.venv")
|
||||
assert "source" in cmd
|
||||
assert "bin/activate" in cmd
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# System Dep Check Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestSystemDeps(unittest.TestCase):
|
||||
"""Tests for system dependency checks."""
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which", return_value=None)
|
||||
def test_tesseract_not_installed(self, mock_which):
|
||||
result = check_tesseract()
|
||||
assert result["installed"] is False
|
||||
assert result["has_eng"] is False
|
||||
assert isinstance(result["install_cmd"], str)
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which", return_value="/usr/bin/tesseract")
|
||||
def test_tesseract_installed_with_eng(self, mock_which, mock_run):
|
||||
mock_run.side_effect = [
|
||||
# --version call
|
||||
MagicMock(returncode=0, stdout="tesseract 5.3.0\n", stderr=""),
|
||||
# --list-langs call
|
||||
MagicMock(returncode=0, stdout="List of available languages:\neng\nosd\n", stderr=""),
|
||||
]
|
||||
result = check_tesseract()
|
||||
assert result["installed"] is True
|
||||
assert result["has_eng"] is True
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
@patch("skill_seekers.cli.video_setup.shutil.which", return_value="/usr/bin/tesseract")
|
||||
def test_tesseract_installed_no_eng(self, mock_which, mock_run):
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="tesseract 5.3.0\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="List of available languages:\nosd\n", stderr=""),
|
||||
]
|
||||
result = check_tesseract()
|
||||
assert result["installed"] is True
|
||||
assert result["has_eng"] is False
|
||||
|
||||
def test_detect_distro_returns_string(self):
|
||||
result = _detect_distro()
|
||||
assert isinstance(result, str)
|
||||
|
||||
@patch("builtins.open", side_effect=OSError)
|
||||
def test_detect_distro_no_os_release(self, mock_open):
|
||||
assert _detect_distro() == "unknown"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ROCm Configuration Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestROCmConfig(unittest.TestCase):
|
||||
"""Tests for configure_rocm_env()."""
|
||||
|
||||
def test_sets_miopen_find_mode(self):
|
||||
env_backup = os.environ.get("MIOPEN_FIND_MODE")
|
||||
try:
|
||||
os.environ.pop("MIOPEN_FIND_MODE", None)
|
||||
changes = configure_rocm_env()
|
||||
assert "MIOPEN_FIND_MODE=FAST" in changes
|
||||
assert os.environ["MIOPEN_FIND_MODE"] == "FAST"
|
||||
finally:
|
||||
if env_backup is not None:
|
||||
os.environ["MIOPEN_FIND_MODE"] = env_backup
|
||||
|
||||
def test_does_not_override_existing(self):
|
||||
env_backup = os.environ.get("MIOPEN_FIND_MODE")
|
||||
try:
|
||||
os.environ["MIOPEN_FIND_MODE"] = "NORMAL"
|
||||
changes = configure_rocm_env()
|
||||
miopen_changes = [c for c in changes if "MIOPEN_FIND_MODE" in c]
|
||||
assert len(miopen_changes) == 0
|
||||
assert os.environ["MIOPEN_FIND_MODE"] == "NORMAL"
|
||||
finally:
|
||||
if env_backup is not None:
|
||||
os.environ["MIOPEN_FIND_MODE"] = env_backup
|
||||
else:
|
||||
os.environ.pop("MIOPEN_FIND_MODE", None)
|
||||
|
||||
def test_sets_miopen_user_db_path(self):
|
||||
env_backup = os.environ.get("MIOPEN_USER_DB_PATH")
|
||||
try:
|
||||
os.environ.pop("MIOPEN_USER_DB_PATH", None)
|
||||
changes = configure_rocm_env()
|
||||
db_changes = [c for c in changes if "MIOPEN_USER_DB_PATH" in c]
|
||||
assert len(db_changes) == 1
|
||||
finally:
|
||||
if env_backup is not None:
|
||||
os.environ["MIOPEN_USER_DB_PATH"] = env_backup
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Module Selection Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestModuleSelection(unittest.TestCase):
|
||||
"""Tests for SetupModules and _build_visual_deps."""
|
||||
|
||||
def test_default_modules_all_true(self):
|
||||
m = SetupModules()
|
||||
assert m.torch is True
|
||||
assert m.easyocr is True
|
||||
assert m.opencv is True
|
||||
assert m.tesseract is True
|
||||
assert m.scenedetect is True
|
||||
assert m.whisper is True
|
||||
|
||||
def test_build_all_deps(self):
|
||||
deps = _build_visual_deps(SetupModules())
|
||||
assert "yt-dlp" in deps
|
||||
assert "youtube-transcript-api" in deps
|
||||
assert "easyocr" in deps
|
||||
assert "opencv-python-headless" in deps
|
||||
assert "pytesseract" in deps
|
||||
assert "scenedetect[opencv]" in deps
|
||||
assert "faster-whisper" in deps
|
||||
|
||||
def test_build_no_optional_deps(self):
|
||||
"""Even with all optional modules off, base video deps are included."""
|
||||
m = SetupModules(
|
||||
torch=False, easyocr=False, opencv=False,
|
||||
tesseract=False, scenedetect=False, whisper=False,
|
||||
)
|
||||
deps = _build_visual_deps(m)
|
||||
assert deps == list(_BASE_VIDEO_DEPS)
|
||||
|
||||
def test_build_partial_deps(self):
|
||||
m = SetupModules(easyocr=True, opencv=True, tesseract=False, scenedetect=False, whisper=False)
|
||||
deps = _build_visual_deps(m)
|
||||
assert "yt-dlp" in deps
|
||||
assert "youtube-transcript-api" in deps
|
||||
assert "easyocr" in deps
|
||||
assert "opencv-python-headless" in deps
|
||||
assert "pytesseract" not in deps
|
||||
assert "faster-whisper" not in deps
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Installation Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestInstallation(unittest.TestCase):
|
||||
"""Tests for install_torch() and install_visual_deps()."""
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_torch_success(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
gpu = GPUInfo(vendor=GPUVendor.NVIDIA, index_url=f"{_PYTORCH_BASE}/cu124")
|
||||
assert install_torch(gpu) is True
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "torch" in call_args
|
||||
assert "--index-url" in call_args
|
||||
assert f"{_PYTORCH_BASE}/cu124" in call_args
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_torch_cpu(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
gpu = GPUInfo(vendor=GPUVendor.NONE, index_url=f"{_PYTORCH_BASE}/cpu")
|
||||
assert install_torch(gpu) is True
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert f"{_PYTORCH_BASE}/cpu" in call_args
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_torch_failure(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error msg")
|
||||
gpu = GPUInfo(vendor=GPUVendor.NVIDIA, index_url=f"{_PYTORCH_BASE}/cu124")
|
||||
assert install_torch(gpu) is False
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_torch_timeout(self, mock_run):
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="pip", timeout=600)
|
||||
gpu = GPUInfo(vendor=GPUVendor.NVIDIA, index_url=f"{_PYTORCH_BASE}/cu124")
|
||||
assert install_torch(gpu) is False
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_torch_custom_python(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
gpu = GPUInfo(vendor=GPUVendor.NONE, index_url=f"{_PYTORCH_BASE}/cpu")
|
||||
install_torch(gpu, python_exe="/custom/python")
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args[0] == "/custom/python"
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_visual_deps_success(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert install_visual_deps() is True
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "easyocr" in call_args
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_visual_deps_failure(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
assert install_visual_deps() is False
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_visual_deps_partial_modules(self, mock_run):
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
modules = SetupModules(easyocr=True, opencv=False, tesseract=False, scenedetect=False, whisper=False)
|
||||
install_visual_deps(modules)
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "easyocr" in call_args
|
||||
assert "opencv-python-headless" not in call_args
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.subprocess.run")
|
||||
def test_install_visual_deps_base_only(self, mock_run):
|
||||
"""Even with all optional modules off, base video deps get installed."""
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
modules = SetupModules(easyocr=False, opencv=False, tesseract=False, scenedetect=False, whisper=False)
|
||||
result = install_visual_deps(modules)
|
||||
assert result is True
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "yt-dlp" in call_args
|
||||
assert "youtube-transcript-api" in call_args
|
||||
assert "easyocr" not in call_args
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Verification Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestVerification(unittest.TestCase):
|
||||
"""Tests for verify_installation()."""
|
||||
|
||||
@patch.dict("sys.modules", {"torch": None, "easyocr": None, "cv2": None})
|
||||
def test_returns_dict(self):
|
||||
results = verify_installation()
|
||||
assert isinstance(results, dict)
|
||||
|
||||
def test_expected_keys(self):
|
||||
results = verify_installation()
|
||||
for key in ("yt-dlp", "youtube-transcript-api", "torch", "torch.cuda", "torch.rocm", "easyocr", "opencv"):
|
||||
assert key in results, f"Missing key: {key}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Orchestrator Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestRunSetup(unittest.TestCase):
|
||||
"""Tests for run_setup() orchestrator."""
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.verify_installation")
|
||||
@patch("skill_seekers.cli.video_setup.install_visual_deps", return_value=True)
|
||||
@patch("skill_seekers.cli.video_setup.install_torch", return_value=True)
|
||||
@patch("skill_seekers.cli.video_setup.check_tesseract")
|
||||
@patch("skill_seekers.cli.video_setup.detect_gpu")
|
||||
def test_non_interactive_success(self, mock_detect, mock_tess, mock_torch, mock_deps, mock_verify):
|
||||
mock_detect.return_value = GPUInfo(
|
||||
vendor=GPUVendor.NONE, name="CPU-only", index_url=f"{_PYTORCH_BASE}/cpu",
|
||||
)
|
||||
mock_tess.return_value = {"installed": True, "has_eng": True, "install_cmd": "", "version": "5.3.0"}
|
||||
mock_verify.return_value = {
|
||||
"torch": True, "torch.cuda": False, "torch.rocm": False,
|
||||
"easyocr": True, "opencv": True, "pytesseract": True,
|
||||
"scenedetect": True, "faster-whisper": True,
|
||||
}
|
||||
rc = run_setup(interactive=False)
|
||||
assert rc == 0
|
||||
mock_torch.assert_called_once()
|
||||
mock_deps.assert_called_once()
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.install_torch", return_value=False)
|
||||
@patch("skill_seekers.cli.video_setup.check_tesseract")
|
||||
@patch("skill_seekers.cli.video_setup.detect_gpu")
|
||||
def test_failure_returns_nonzero(self, mock_detect, mock_tess, mock_torch):
|
||||
mock_detect.return_value = GPUInfo(
|
||||
vendor=GPUVendor.NONE, name="CPU-only", index_url=f"{_PYTORCH_BASE}/cpu",
|
||||
)
|
||||
mock_tess.return_value = {"installed": True, "has_eng": True, "install_cmd": "", "version": "5.3.0"}
|
||||
rc = run_setup(interactive=False)
|
||||
assert rc == 1
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.install_torch", return_value=True)
|
||||
@patch("skill_seekers.cli.video_setup.install_visual_deps", return_value=False)
|
||||
@patch("skill_seekers.cli.video_setup.check_tesseract")
|
||||
@patch("skill_seekers.cli.video_setup.detect_gpu")
|
||||
def test_visual_deps_failure(self, mock_detect, mock_tess, mock_deps, mock_torch):
|
||||
mock_detect.return_value = GPUInfo(
|
||||
vendor=GPUVendor.NONE, name="CPU-only", index_url=f"{_PYTORCH_BASE}/cpu",
|
||||
)
|
||||
mock_tess.return_value = {"installed": True, "has_eng": True, "install_cmd": "", "version": "5.3.0"}
|
||||
rc = run_setup(interactive=False)
|
||||
assert rc == 1
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.verify_installation")
|
||||
@patch("skill_seekers.cli.video_setup.install_visual_deps", return_value=True)
|
||||
@patch("skill_seekers.cli.video_setup.install_torch", return_value=True)
|
||||
@patch("skill_seekers.cli.video_setup.check_tesseract")
|
||||
@patch("skill_seekers.cli.video_setup.detect_gpu")
|
||||
def test_rocm_configures_env(self, mock_detect, mock_tess, mock_torch, mock_deps, mock_verify):
|
||||
"""AMD GPU → configure_rocm_env called and env vars set."""
|
||||
mock_detect.return_value = GPUInfo(
|
||||
vendor=GPUVendor.AMD, name="RX 7900", index_url=f"{_PYTORCH_BASE}/rocm6.3",
|
||||
)
|
||||
mock_tess.return_value = {"installed": True, "has_eng": True, "install_cmd": "", "version": "5.3.0"}
|
||||
mock_verify.return_value = {
|
||||
"torch": True, "torch.cuda": False, "torch.rocm": True,
|
||||
"easyocr": True, "opencv": True, "pytesseract": True,
|
||||
"scenedetect": True, "faster-whisper": True,
|
||||
}
|
||||
rc = run_setup(interactive=False)
|
||||
assert rc == 0
|
||||
assert os.environ.get("MIOPEN_FIND_MODE") is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tesseract Circuit Breaker Tests (video_visual.py)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestTesseractCircuitBreaker(unittest.TestCase):
|
||||
"""Tests for _tesseract_broken flag in video_visual.py."""
|
||||
|
||||
def test_circuit_breaker_flag_exists(self):
|
||||
import skill_seekers.cli.video_visual as vv
|
||||
assert hasattr(vv, "_tesseract_broken")
|
||||
|
||||
def test_circuit_breaker_skips_after_failure(self):
|
||||
import skill_seekers.cli.video_visual as vv
|
||||
from skill_seekers.cli.video_models import FrameType
|
||||
|
||||
# Save and set broken state
|
||||
original = vv._tesseract_broken
|
||||
try:
|
||||
vv._tesseract_broken = True
|
||||
result = vv._run_tesseract_ocr("/nonexistent/path.png", FrameType.CODE_EDITOR)
|
||||
assert result == []
|
||||
finally:
|
||||
vv._tesseract_broken = original
|
||||
|
||||
def test_circuit_breaker_allows_when_not_broken(self):
|
||||
import skill_seekers.cli.video_visual as vv
|
||||
from skill_seekers.cli.video_models import FrameType
|
||||
|
||||
original = vv._tesseract_broken
|
||||
try:
|
||||
vv._tesseract_broken = False
|
||||
if not vv.HAS_PYTESSERACT:
|
||||
# pytesseract not installed → returns [] immediately
|
||||
result = vv._run_tesseract_ocr("/nonexistent/path.png", FrameType.CODE_EDITOR)
|
||||
assert result == []
|
||||
# If pytesseract IS installed, it would try to run and potentially fail
|
||||
# on our fake path — that's fine, the circuit breaker would trigger
|
||||
finally:
|
||||
vv._tesseract_broken = original
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MIOPEN Env Var Tests (video_visual.py)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestMIOPENEnvVars(unittest.TestCase):
|
||||
"""Tests that video_visual.py sets MIOPEN env vars at import time."""
|
||||
|
||||
def test_miopen_find_mode_set(self):
|
||||
# video_visual.py sets this at module level before torch import
|
||||
assert "MIOPEN_FIND_MODE" in os.environ
|
||||
|
||||
def test_miopen_user_db_path_set(self):
|
||||
assert "MIOPEN_USER_DB_PATH" in os.environ
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Argument & Early-Exit Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestVideoArgumentSetup(unittest.TestCase):
|
||||
"""Tests for --setup flag in VIDEO_ARGUMENTS."""
|
||||
|
||||
def test_setup_in_video_arguments(self):
|
||||
from skill_seekers.cli.arguments.video import VIDEO_ARGUMENTS
|
||||
|
||||
assert "setup" in VIDEO_ARGUMENTS
|
||||
assert VIDEO_ARGUMENTS["setup"]["kwargs"]["action"] == "store_true"
|
||||
|
||||
def test_parser_accepts_setup(self):
|
||||
import argparse
|
||||
|
||||
from skill_seekers.cli.arguments.video import add_video_arguments
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
add_video_arguments(parser)
|
||||
args = parser.parse_args(["--setup"])
|
||||
assert args.setup is True
|
||||
|
||||
def test_parser_default_false(self):
|
||||
import argparse
|
||||
|
||||
from skill_seekers.cli.arguments.video import add_video_arguments
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
add_video_arguments(parser)
|
||||
args = parser.parse_args(["--url", "https://example.com"])
|
||||
assert args.setup is False
|
||||
|
||||
|
||||
class TestVideoScraperSetupEarlyExit(unittest.TestCase):
|
||||
"""Test that --setup exits before source validation."""
|
||||
|
||||
@patch("skill_seekers.cli.video_setup.run_setup", return_value=0)
|
||||
def test_setup_skips_source_validation(self, mock_setup):
|
||||
"""--setup without --url should NOT error about missing source."""
|
||||
from skill_seekers.cli.video_scraper import main
|
||||
|
||||
old_argv = sys.argv
|
||||
try:
|
||||
sys.argv = ["video_scraper", "--setup"]
|
||||
rc = main()
|
||||
assert rc == 0
|
||||
mock_setup.assert_called_once_with(interactive=True)
|
||||
finally:
|
||||
sys.argv = old_argv
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user