"""Tests for UV Package Installer module.""" import pytest from pathlib import Path from unittest.mock import Mock, patch, mock_open import tempfile import shutil from mcp_forge.builder.uv_installer import UVInstaller @pytest.fixture def cache_path(tmp_path): """Temporary cache directory.""" cache_dir = tmp_path / "uv_cache" return cache_dir @pytest.fixture def installer(cache_path): """UVInstaller instance.""" return UVInstaller(cache_path) def test_init_creates_cache_directory(cache_path): """Test that __init__ creates cache directory.""" assert not cache_path.exists() installer = UVInstaller(cache_path) assert cache_path.exists() assert cache_path.is_dir() def test_init_with_existing_cache(cache_path): """Test initialization with existing cache directory.""" cache_path.mkdir(parents=True) installer = UVInstaller(cache_path) assert cache_path.exists() def test_generate_requirements_single_package(installer): """Test generating requirements.txt with single package.""" packages = ["numpy>=1.24.0"] requirements = installer.generate_requirements(packages) assert requirements == "numpy>=1.24.0" def test_generate_requirements_multiple_packages(installer): """Test generating requirements.txt with multiple packages.""" packages = ["numpy>=1.24.0", "pandas==2.0.0", "requests"] requirements = installer.generate_requirements(packages) lines = requirements.strip().split('\n') assert len(lines) == 3 assert "numpy>=1.24.0" in lines assert "pandas==2.0.0" in lines assert "requests" in lines def test_generate_requirements_empty_list(installer): """Test generating requirements.txt with empty list.""" packages = [] requirements = installer.generate_requirements(packages) assert requirements == "" def test_generate_containerfile_structure(installer): """Test Containerfile has correct structure.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy>=1.24.0", "pandas"] containerfile = installer.generate_containerfile(base_image, packages) # Check key sections are present assert f"FROM {base_image}" in containerfile assert "pip install" in containerfile and "uv" in containerfile assert "COPY" in containerfile and "requirements.txt" in containerfile assert "RUN uv pip install" in containerfile assert "WORKDIR" in containerfile def test_generate_containerfile_with_python_version(installer): """Test Containerfile generation with specific Python version.""" base_image = "docker.io/python:3.12-slim" packages = ["numpy"] containerfile = installer.generate_containerfile( base_image, packages, python_version="3.12" ) assert "3.12" in containerfile or "python:3.12" in base_image def test_generate_containerfile_uses_requirements(installer): """Test Containerfile copies and uses requirements.txt.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy", "pandas"] containerfile = installer.generate_containerfile(base_image, packages) # Should copy requirements.txt for layer caching assert "COPY" in containerfile and "requirements.txt" in containerfile assert "-r requirements.txt" in containerfile def test_generate_containerfile_creates_user(installer): """Test Containerfile creates non-root user.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy"] containerfile = installer.generate_containerfile(base_image, packages) # Should create user for security assert "useradd" in containerfile.lower() or "USER" in containerfile def test_create_build_context_creates_directory(installer): """Test that build context directory is created.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy", "pandas"] context_path = installer.create_build_context(base_image, packages) try: assert context_path.exists() assert context_path.is_dir() finally: if context_path.exists(): shutil.rmtree(context_path) def test_create_build_context_contains_containerfile(installer): """Test that build context contains Containerfile.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy"] context_path = installer.create_build_context(base_image, packages) try: containerfile_path = context_path / "Containerfile" assert containerfile_path.exists() content = containerfile_path.read_text() assert f"FROM {base_image}" in content finally: if context_path.exists(): shutil.rmtree(context_path) def test_create_build_context_contains_requirements(installer): """Test that build context contains requirements.txt.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy>=1.24.0", "pandas"] context_path = installer.create_build_context(base_image, packages) try: requirements_path = context_path / "requirements.txt" assert requirements_path.exists() content = requirements_path.read_text() assert "numpy>=1.24.0" in content assert "pandas" in content finally: if context_path.exists(): shutil.rmtree(context_path) def test_create_build_context_returns_temp_directory(installer): """Test that build context is in temp directory.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy"] context_path = installer.create_build_context(base_image, packages) try: # Should be in system temp directory temp_dir = Path(tempfile.gettempdir()) assert temp_dir in context_path.parents finally: if context_path.exists(): shutil.rmtree(context_path) def test_get_cache_volume_mount_returns_dict(installer): """Test cache volume mount returns proper dict.""" mount_config = installer.get_cache_volume_mount() assert isinstance(mount_config, dict) assert "bind" in mount_config assert "mode" in mount_config assert mount_config["mode"] == "rw" def test_get_cache_volume_mount_includes_cache_path(installer, cache_path): """Test cache volume mount includes cache path.""" mount_config = installer.get_cache_volume_mount() # The bind target should reference UV cache location assert "bind" in mount_config assert "/cache" in mount_config["bind"] or "uv" in mount_config["bind"].lower() def test_generate_containerfile_security_practices(installer): """Test Containerfile follows security best practices.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy"] containerfile = installer.generate_containerfile(base_image, packages) # Should not run as root assert "USER" in containerfile # Should set working directory assert "WORKDIR" in containerfile def test_generate_requirements_preserves_version_specs(installer): """Test that version specifiers are preserved exactly.""" packages = [ "numpy>=1.24.0,<2.0.0", "pandas==2.0.0", "requests~=2.28.0", "scipy!=1.10.0" ] requirements = installer.generate_requirements(packages) for package in packages: assert package in requirements def test_generate_containerfile_with_extras(installer): """Test Containerfile works with package extras.""" base_image = "docker.io/python:3.11-slim" packages = ["requests[security]>=2.28.0", "pandas[excel]"] containerfile = installer.generate_containerfile(base_image, packages) # Should handle extras in requirements assert "COPY" in containerfile and "requirements.txt" in containerfile assert "RUN uv pip install" in containerfile def test_create_build_context_with_custom_python_version(installer): """Test build context creation with custom Python version.""" base_image = "docker.io/python:3.12-slim" packages = ["numpy"] context_path = installer.create_build_context( base_image, packages, python_version="3.12" ) try: containerfile = (context_path / "Containerfile").read_text() assert "3.12" in containerfile or "python:3.12" in containerfile finally: if context_path.exists(): shutil.rmtree(context_path) def test_generate_containerfile_optimizes_layer_caching(installer): """Test that Containerfile structure optimizes Docker layer caching.""" base_image = "docker.io/python:3.11-slim" packages = ["numpy", "pandas", "scipy"] containerfile = installer.generate_containerfile(base_image, packages) lines = containerfile.split('\n') # UV install should come before requirements copy uv_install_idx = None requirements_idx = None for i, line in enumerate(lines): if 'uv' in line.lower() and ('pip install' in line.lower() or 'ADD' in line): uv_install_idx = i if 'COPY' in line and 'requirements.txt' in line: requirements_idx = i # UV installation should be cached separately assert uv_install_idx is not None # Requirements copy should happen for cache busting assert requirements_idx is not None def test_empty_packages_list_creates_valid_containerfile(installer): """Test that empty package list still creates valid Containerfile.""" base_image = "docker.io/python:3.11-slim" packages = [] containerfile = installer.generate_containerfile(base_image, packages) # Should still have base structure assert f"FROM {base_image}" in containerfile assert "WORKDIR" in containerfile