- Fix extra dependencies being filtered out by Python version checks - Add proper handling for extra markers in dependency parsing - Update parameter descriptions and documentation - Add comprehensive examples and demo script - Test with requests[socks], django[argon2,bcrypt], setuptools[test]
305 lines
12 KiB
Python
305 lines
12 KiB
Python
"""Tests for dependency resolver functionality."""
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from pypi_query_mcp.core.exceptions import InvalidPackageNameError, PackageNotFoundError
|
|
from pypi_query_mcp.tools.dependency_resolver import (
|
|
DependencyResolver,
|
|
resolve_package_dependencies,
|
|
)
|
|
|
|
|
|
class TestDependencyResolver:
|
|
"""Test cases for DependencyResolver class."""
|
|
|
|
@pytest.fixture
|
|
def resolver(self):
|
|
"""Create a DependencyResolver instance for testing."""
|
|
return DependencyResolver(max_depth=3)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_invalid_package_name(self, resolver):
|
|
"""Test that invalid package names raise appropriate errors."""
|
|
with pytest.raises(InvalidPackageNameError):
|
|
await resolver.resolve_dependencies("")
|
|
|
|
with pytest.raises(InvalidPackageNameError):
|
|
await resolver.resolve_dependencies(" ")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_basic(self, resolver):
|
|
"""Test basic dependency resolution."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": ["requests>=2.25.0", "click>=8.0.0"],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.return_value = mock_package_data
|
|
|
|
result = await resolver.resolve_dependencies("test-package")
|
|
|
|
assert result["package_name"] == "test-package"
|
|
assert "dependency_tree" in result
|
|
assert "summary" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_with_python_version(self, resolver):
|
|
"""Test dependency resolution with Python version filtering."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": [
|
|
"requests>=2.25.0",
|
|
"typing-extensions>=4.0.0; python_version<'3.10'",
|
|
],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.return_value = mock_package_data
|
|
|
|
result = await resolver.resolve_dependencies(
|
|
"test-package", python_version="3.11"
|
|
)
|
|
|
|
assert result["python_version"] == "3.11"
|
|
assert "dependency_tree" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_with_extras(self, resolver):
|
|
"""Test dependency resolution with extra dependencies."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "mock-test-package-12345",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": ["requests>=2.25.0", "pytest>=6.0.0; extra=='test'"],
|
|
}
|
|
}
|
|
|
|
# Mock for transitive dependencies
|
|
mock_requests_data = {
|
|
"info": {
|
|
"name": "requests",
|
|
"version": "2.25.0",
|
|
"requires_python": ">=3.6",
|
|
"requires_dist": [],
|
|
}
|
|
}
|
|
|
|
mock_pytest_data = {
|
|
"info": {
|
|
"name": "pytest",
|
|
"version": "6.0.0",
|
|
"requires_python": ">=3.6",
|
|
"requires_dist": [],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
|
|
# Setup mock to return different data based on package name
|
|
def mock_get_package_info(package_name):
|
|
if package_name.lower() == "mock-test-package-12345":
|
|
return mock_package_data
|
|
elif package_name.lower() == "requests":
|
|
return mock_requests_data
|
|
elif package_name.lower() == "pytest":
|
|
return mock_pytest_data
|
|
else:
|
|
return {"info": {"name": package_name, "version": "1.0.0", "requires_dist": []}}
|
|
|
|
mock_client.get_package_info.side_effect = mock_get_package_info
|
|
|
|
result = await resolver.resolve_dependencies(
|
|
"mock-test-package-12345", include_extras=["test"], max_depth=2
|
|
)
|
|
|
|
assert result["include_extras"] == ["test"]
|
|
assert "dependency_tree" in result
|
|
|
|
# Verify that extras are properly resolved and included
|
|
assert result["summary"]["total_extra_dependencies"] == 1
|
|
main_pkg = result["dependency_tree"]["mock-test-package-12345"]
|
|
assert "test" in main_pkg["dependencies"]["extras"]
|
|
assert len(main_pkg["dependencies"]["extras"]["test"]) == 1
|
|
assert "pytest" in main_pkg["dependencies"]["extras"]["test"][0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_with_extras_and_python_version(self, resolver):
|
|
"""Test that extras work correctly with Python version filtering."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": [
|
|
"requests>=2.25.0",
|
|
"typing-extensions>=4.0.0; python_version<'3.10'",
|
|
"pytest>=6.0.0; extra=='test'",
|
|
"coverage>=5.0; extra=='test'",
|
|
],
|
|
}
|
|
}
|
|
|
|
# Mock for transitive dependencies
|
|
mock_requests_data = {
|
|
"info": {
|
|
"name": "requests",
|
|
"version": "2.25.0",
|
|
"requires_python": ">=3.6",
|
|
"requires_dist": [],
|
|
}
|
|
}
|
|
|
|
mock_pytest_data = {
|
|
"info": {
|
|
"name": "pytest",
|
|
"version": "6.0.0",
|
|
"requires_python": ">=3.6",
|
|
"requires_dist": [],
|
|
}
|
|
}
|
|
|
|
mock_coverage_data = {
|
|
"info": {
|
|
"name": "coverage",
|
|
"version": "5.0.0",
|
|
"requires_python": ">=3.6",
|
|
"requires_dist": [],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
|
|
# Setup mock to return different data based on package name
|
|
def mock_get_package_info(package_name):
|
|
if package_name.lower() == "test-package":
|
|
return mock_package_data
|
|
elif package_name.lower() == "requests":
|
|
return mock_requests_data
|
|
elif package_name.lower() == "pytest":
|
|
return mock_pytest_data
|
|
elif package_name.lower() == "coverage":
|
|
return mock_coverage_data
|
|
else:
|
|
return {"info": {"name": package_name, "version": "1.0.0", "requires_dist": []}}
|
|
|
|
mock_client.get_package_info.side_effect = mock_get_package_info
|
|
|
|
# Test with Python 3.11 - should not include typing-extensions but should include extras
|
|
result = await resolver.resolve_dependencies(
|
|
"test-package", python_version="3.11", include_extras=["test"], max_depth=2
|
|
)
|
|
|
|
assert result["include_extras"] == ["test"]
|
|
assert result["python_version"] == "3.11"
|
|
|
|
# Verify that extras are properly resolved
|
|
assert result["summary"]["total_extra_dependencies"] == 2
|
|
main_pkg = result["dependency_tree"]["test-package"]
|
|
assert "test" in main_pkg["dependencies"]["extras"]
|
|
assert len(main_pkg["dependencies"]["extras"]["test"]) == 2
|
|
|
|
# Verify Python version filtering worked for runtime deps but not extras
|
|
runtime_deps = main_pkg["dependencies"]["runtime"]
|
|
assert len(runtime_deps) == 1 # Only requests, not typing-extensions
|
|
assert "requests" in runtime_deps[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_dependencies_max_depth(self, resolver):
|
|
"""Test that max depth is respected."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": ["requests>=2.25.0"],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.return_value = mock_package_data
|
|
|
|
result = await resolver.resolve_dependencies("test-package", max_depth=1)
|
|
|
|
assert result["summary"]["max_depth"] <= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_package_dependencies_function(self):
|
|
"""Test the standalone resolve_package_dependencies function."""
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": ["requests>=2.25.0"],
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.return_value = mock_package_data
|
|
|
|
result = await resolve_package_dependencies("test-package")
|
|
|
|
assert result["package_name"] == "test-package"
|
|
assert "dependency_tree" in result
|
|
assert "summary" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_circular_dependency_handling(self, resolver):
|
|
"""Test that circular dependencies are handled properly."""
|
|
# This is a simplified test - in reality, circular dependencies
|
|
# are prevented by the visited set
|
|
mock_package_data = {
|
|
"info": {
|
|
"name": "test-package",
|
|
"version": "1.0.0",
|
|
"requires_python": ">=3.8",
|
|
"requires_dist": ["test-package>=1.0.0"], # Self-dependency
|
|
}
|
|
}
|
|
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.return_value = mock_package_data
|
|
|
|
# Should not hang or crash
|
|
result = await resolver.resolve_dependencies("test-package")
|
|
assert "dependency_tree" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_package_not_found_handling(self, resolver):
|
|
"""Test handling of packages that are not found."""
|
|
with patch("pypi_query_mcp.core.PyPIClient") as mock_client_class:
|
|
mock_client = AsyncMock()
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
|
mock_client.get_package_info.side_effect = PackageNotFoundError(
|
|
"Package not found"
|
|
)
|
|
|
|
with pytest.raises(PackageNotFoundError):
|
|
await resolver.resolve_dependencies("nonexistent-package")
|