pypi-query-mcp/tests/test_search.py
Ryan Malloy e205176ace feat: add comprehensive PyPI search functionality with advanced filtering
- 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"
2025-08-16 07:45:40 -06:00

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