393 lines
16 KiB
Python
393 lines
16 KiB
Python
|
|
"""Tests for PyPI search functionality."""
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from unittest.mock import AsyncMock, patch
|
||
|
|
|
||
|
|
from pypi_query_mcp.core.search_client import PyPISearchClient, SearchFilter, SearchSort
|
||
|
|
from pypi_query_mcp.tools.search import (
|
||
|
|
find_alternatives,
|
||
|
|
get_trending_packages,
|
||
|
|
search_by_category,
|
||
|
|
search_packages,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestSearchPackages:
|
||
|
|
"""Test the search_packages function."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_basic_search(self):
|
||
|
|
"""Test basic package search functionality."""
|
||
|
|
# Mock the search client
|
||
|
|
with patch("pypi_query_mcp.tools.search.PyPISearchClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
mock_result = {
|
||
|
|
"query": "flask",
|
||
|
|
"total_found": 5,
|
||
|
|
"filtered_count": 5,
|
||
|
|
"returned_count": 5,
|
||
|
|
"packages": [
|
||
|
|
{
|
||
|
|
"name": "Flask",
|
||
|
|
"summary": "A micro web framework",
|
||
|
|
"version": "2.3.3",
|
||
|
|
"license_type": "bsd",
|
||
|
|
"categories": ["web"],
|
||
|
|
"quality_score": 95.0,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
"filters_applied": {},
|
||
|
|
"sort_applied": {"field": "relevance", "reverse": True},
|
||
|
|
"semantic_search": False,
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.search_packages.return_value = mock_result
|
||
|
|
|
||
|
|
result = await search_packages(query="flask", limit=20)
|
||
|
|
|
||
|
|
assert result["query"] == "flask"
|
||
|
|
assert len(result["packages"]) == 1
|
||
|
|
assert result["packages"][0]["name"] == "Flask"
|
||
|
|
mock_client.search_packages.assert_called_once()
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_search_with_filters(self):
|
||
|
|
"""Test search with filtering options."""
|
||
|
|
with patch("pypi_query_mcp.tools.search.PyPISearchClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
mock_result = {
|
||
|
|
"query": "web framework",
|
||
|
|
"total_found": 10,
|
||
|
|
"filtered_count": 3,
|
||
|
|
"returned_count": 3,
|
||
|
|
"packages": [
|
||
|
|
{"name": "Flask", "license_type": "bsd", "categories": ["web"]},
|
||
|
|
{"name": "Django", "license_type": "bsd", "categories": ["web"]},
|
||
|
|
{"name": "FastAPI", "license_type": "mit", "categories": ["web"]},
|
||
|
|
],
|
||
|
|
"filters_applied": {
|
||
|
|
"python_versions": ["3.9"],
|
||
|
|
"licenses": ["mit", "bsd"],
|
||
|
|
"categories": ["web"],
|
||
|
|
"min_downloads": 1000,
|
||
|
|
},
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.search_packages.return_value = mock_result
|
||
|
|
|
||
|
|
result = await search_packages(
|
||
|
|
query="web framework",
|
||
|
|
python_versions=["3.9"],
|
||
|
|
licenses=["mit", "bsd"],
|
||
|
|
categories=["web"],
|
||
|
|
min_downloads=1000,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["filtered_count"] == 3
|
||
|
|
assert all(pkg["categories"] == ["web"] for pkg in result["packages"])
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_empty_query_error(self):
|
||
|
|
"""Test that empty query raises appropriate error."""
|
||
|
|
from pypi_query_mcp.core.exceptions import InvalidPackageNameError
|
||
|
|
|
||
|
|
with pytest.raises(InvalidPackageNameError):
|
||
|
|
await search_packages(query="")
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_search_with_semantic_search(self):
|
||
|
|
"""Test search with semantic search enabled."""
|
||
|
|
with patch("pypi_query_mcp.tools.search.PyPISearchClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
mock_result = {
|
||
|
|
"query": "machine learning",
|
||
|
|
"packages": [
|
||
|
|
{"name": "scikit-learn", "semantic_score": 0.95},
|
||
|
|
{"name": "pandas", "semantic_score": 0.80},
|
||
|
|
],
|
||
|
|
"semantic_search": True,
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.search_packages.return_value = mock_result
|
||
|
|
|
||
|
|
result = await search_packages(
|
||
|
|
query="machine learning",
|
||
|
|
semantic_search=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["semantic_search"] is True
|
||
|
|
assert result["packages"][0]["semantic_score"] == 0.95
|
||
|
|
|
||
|
|
|
||
|
|
class TestSearchByCategory:
|
||
|
|
"""Test the search_by_category function."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_web_category_search(self):
|
||
|
|
"""Test searching for web packages."""
|
||
|
|
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
|
||
|
|
mock_result = {
|
||
|
|
"query": "web framework flask django fastapi",
|
||
|
|
"packages": [
|
||
|
|
{"name": "Flask", "categories": ["web"]},
|
||
|
|
{"name": "Django", "categories": ["web"]},
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_search.return_value = mock_result
|
||
|
|
|
||
|
|
result = await search_by_category(category="web", limit=10)
|
||
|
|
|
||
|
|
assert len(result["packages"]) == 2
|
||
|
|
mock_search.assert_called_once_with(
|
||
|
|
query="web framework flask django fastapi",
|
||
|
|
limit=10,
|
||
|
|
categories=["web"],
|
||
|
|
python_versions=None,
|
||
|
|
sort_by="popularity",
|
||
|
|
semantic_search=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_data_science_category(self):
|
||
|
|
"""Test searching for data science packages."""
|
||
|
|
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
|
||
|
|
mock_result = {
|
||
|
|
"query": "data science machine learning pandas numpy",
|
||
|
|
"packages": [
|
||
|
|
{"name": "pandas", "categories": ["data-science"]},
|
||
|
|
{"name": "numpy", "categories": ["data-science"]},
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_search.return_value = mock_result
|
||
|
|
|
||
|
|
result = await search_by_category(
|
||
|
|
category="data-science",
|
||
|
|
python_version="3.10"
|
||
|
|
)
|
||
|
|
|
||
|
|
mock_search.assert_called_once_with(
|
||
|
|
query="data science machine learning pandas numpy",
|
||
|
|
limit=20,
|
||
|
|
categories=["data-science"],
|
||
|
|
python_versions=["3.10"],
|
||
|
|
sort_by="popularity",
|
||
|
|
semantic_search=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestFindAlternatives:
|
||
|
|
"""Test the find_alternatives function."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_find_flask_alternatives(self):
|
||
|
|
"""Test finding alternatives to Flask."""
|
||
|
|
with patch("pypi_query_mcp.core.pypi_client.PyPIClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
# Mock Flask package data
|
||
|
|
mock_flask_data = {
|
||
|
|
"info": {
|
||
|
|
"name": "Flask",
|
||
|
|
"summary": "A micro web framework",
|
||
|
|
"keywords": "web framework micro",
|
||
|
|
"classifiers": [
|
||
|
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||
|
|
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
||
|
|
],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.get_package_info.return_value = mock_flask_data
|
||
|
|
|
||
|
|
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
|
||
|
|
mock_search_result = {
|
||
|
|
"packages": [
|
||
|
|
{"name": "Django", "summary": "High-level web framework"},
|
||
|
|
{"name": "FastAPI", "summary": "Modern web framework"},
|
||
|
|
{"name": "Flask", "summary": "A micro web framework"}, # Original package
|
||
|
|
{"name": "Bottle", "summary": "Micro web framework"},
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_search.return_value = mock_search_result
|
||
|
|
|
||
|
|
result = await find_alternatives(
|
||
|
|
package_name="Flask",
|
||
|
|
limit=5,
|
||
|
|
include_similar=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
# Should exclude the original Flask package
|
||
|
|
assert result["target_package"]["name"] == "Flask"
|
||
|
|
assert len(result["alternatives"]) == 3
|
||
|
|
assert not any(alt["name"] == "Flask" for alt in result["alternatives"])
|
||
|
|
assert result["analysis"]["semantic_search_used"] is True
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_alternatives_with_keywords(self):
|
||
|
|
"""Test alternatives finding using package keywords."""
|
||
|
|
with patch("pypi_query_mcp.core.pypi_client.PyPIClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
mock_package_data = {
|
||
|
|
"info": {
|
||
|
|
"name": "requests",
|
||
|
|
"summary": "HTTP library for Python",
|
||
|
|
"keywords": "http client requests api",
|
||
|
|
"classifiers": ["Topic :: Internet :: WWW/HTTP"],
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.get_package_info.return_value = mock_package_data
|
||
|
|
|
||
|
|
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
|
||
|
|
mock_search.return_value = {
|
||
|
|
"packages": [
|
||
|
|
{"name": "httpx", "summary": "Next generation HTTP client"},
|
||
|
|
{"name": "urllib3", "summary": "HTTP library with connection pooling"},
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
result = await find_alternatives(package_name="requests")
|
||
|
|
|
||
|
|
assert "http client requests api" in result["search_query_used"]
|
||
|
|
assert result["analysis"]["search_method"] == "keyword_similarity"
|
||
|
|
|
||
|
|
|
||
|
|
class TestGetTrendingPackages:
|
||
|
|
"""Test the get_trending_packages function."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_trending_all_categories(self):
|
||
|
|
"""Test getting trending packages across all categories."""
|
||
|
|
with patch("pypi_query_mcp.tools.download_stats.get_top_packages_by_downloads") as mock_top_packages:
|
||
|
|
mock_result = {
|
||
|
|
"top_packages": [
|
||
|
|
{"package": "requests", "downloads": 1000000},
|
||
|
|
{"package": "urllib3", "downloads": 900000},
|
||
|
|
{"package": "certifi", "downloads": 800000},
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_top_packages.return_value = mock_result
|
||
|
|
|
||
|
|
result = await get_trending_packages(
|
||
|
|
time_period="week",
|
||
|
|
limit=10,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["time_period"] == "week"
|
||
|
|
assert result["category"] is None
|
||
|
|
assert len(result["trending_packages"]) == 3
|
||
|
|
assert result["analysis"]["category_filtered"] is False
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_get_trending_by_category(self):
|
||
|
|
"""Test getting trending packages filtered by category."""
|
||
|
|
with patch("pypi_query_mcp.tools.download_stats.get_top_packages_by_downloads") as mock_top_packages:
|
||
|
|
mock_result = {
|
||
|
|
"top_packages": [
|
||
|
|
{"package": "flask", "downloads": 500000},
|
||
|
|
{"package": "django", "downloads": 400000},
|
||
|
|
{"package": "requests", "downloads": 1000000}, # Should be filtered out
|
||
|
|
],
|
||
|
|
"timestamp": "2023-01-01T00:00:00Z",
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_top_packages.return_value = mock_result
|
||
|
|
|
||
|
|
# Mock PyPI client for package metadata
|
||
|
|
with patch("pypi_query_mcp.core.pypi_client.PyPIClient") as mock_client_class:
|
||
|
|
mock_client = AsyncMock()
|
||
|
|
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||
|
|
|
||
|
|
def mock_get_package_info(package_name):
|
||
|
|
if package_name == "flask":
|
||
|
|
return {
|
||
|
|
"info": {
|
||
|
|
"keywords": "web framework micro",
|
||
|
|
"summary": "A micro web framework",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
elif package_name == "django":
|
||
|
|
return {
|
||
|
|
"info": {
|
||
|
|
"keywords": "web framework",
|
||
|
|
"summary": "High-level web framework",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
return {
|
||
|
|
"info": {
|
||
|
|
"keywords": "http client",
|
||
|
|
"summary": "HTTP library",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
mock_client.get_package_info.side_effect = mock_get_package_info
|
||
|
|
|
||
|
|
result = await get_trending_packages(
|
||
|
|
category="web",
|
||
|
|
time_period="month",
|
||
|
|
limit=5,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert result["category"] == "web"
|
||
|
|
assert result["analysis"]["category_filtered"] is True
|
||
|
|
# Should only include web packages (flask, django)
|
||
|
|
assert len(result["trending_packages"]) == 2
|
||
|
|
|
||
|
|
|
||
|
|
class TestSearchClient:
|
||
|
|
"""Test the PyPISearchClient class."""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_client_context_manager(self):
|
||
|
|
"""Test that the search client works as an async context manager."""
|
||
|
|
async with PyPISearchClient() as client:
|
||
|
|
assert client is not None
|
||
|
|
assert hasattr(client, 'search_packages')
|
||
|
|
|
||
|
|
def test_search_filter_creation(self):
|
||
|
|
"""Test SearchFilter creation."""
|
||
|
|
filters = SearchFilter(
|
||
|
|
python_versions=["3.9", "3.10"],
|
||
|
|
licenses=["mit", "apache"],
|
||
|
|
categories=["web", "data-science"],
|
||
|
|
min_downloads=1000,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert filters.python_versions == ["3.9", "3.10"]
|
||
|
|
assert filters.licenses == ["mit", "apache"]
|
||
|
|
assert filters.categories == ["web", "data-science"]
|
||
|
|
assert filters.min_downloads == 1000
|
||
|
|
|
||
|
|
def test_search_sort_creation(self):
|
||
|
|
"""Test SearchSort creation."""
|
||
|
|
sort = SearchSort(field="popularity", reverse=True)
|
||
|
|
|
||
|
|
assert sort.field == "popularity"
|
||
|
|
assert sort.reverse is True
|
||
|
|
|
||
|
|
# Test defaults
|
||
|
|
default_sort = SearchSort()
|
||
|
|
assert default_sort.field == "relevance"
|
||
|
|
assert default_sort.reverse is True
|