304 lines
9.6 KiB
Python
304 lines
9.6 KiB
Python
"""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
|