- Implemented PyPISearchClient with semantic search, filtering, and sorting - Added 4 new search tools: search_packages, search_by_category, find_alternatives, get_trending_packages - Created SearchFilter and SearchSort classes for flexible configuration - Added SearchError exception for search-specific error handling - Comprehensive test suite with 13 tests covering all search functionality - Enhanced MCP server with 4 new search endpoints - Support for filtering by Python version, license, category, downloads, maintenance status - Multiple sorting options: relevance, popularity, quality, recency, name, downloads - Semantic search using description similarity scoring - Category-based package discovery with intelligent keyword matching - Package alternatives finder using metadata analysis - Trending packages analysis with download activity tracking - Robust fallback mechanisms using curated package database - All tests passing (13/13) This implements feature #6 from the roadmap: "Advanced PyPI Search with filtering by Python version, license, maintenance status and sorting by popularity, recency, quality score with semantic search capabilities"
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 |