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"
This commit is contained in:
parent
7ace364f32
commit
e205176ace
6 changed files with 1430 additions and 1 deletions
393
tests/test_search.py
Normal file
393
tests/test_search.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue