pypi-query-mcp/tests/test_discovery.py
Ryan Malloy 1b4ca9f902 feat: Implement PyPI Discovery & Monitoring Tools
Add comprehensive PyPI discovery and monitoring capabilities with 4 core tools:

- monitor_pypi_new_releases: Track new releases by category with real-time alerts
- get_pypi_trending_today: Analyze current trending packages with market insights
- search_pypi_by_maintainer: Find packages by maintainer with portfolio analysis
- get_pypi_package_recommendations: Algorithm-based package recommendations

Features:
- RSS feed integration for real-time release monitoring
- Intelligent caching system with configurable TTL
- Advanced package categorization and filtering
- Trending analysis with multiple data signals
- Personalized recommendations with user context
- Comprehensive error handling and logging
- Full test coverage with mocked dependencies
- MCP server endpoints for all discovery tools

Dependencies:
- Add feedparser for RSS feed parsing
- Enhanced server.py with 4 new MCP tool endpoints
- Updated tools/__init__.py exports

This implementation provides production-ready monitoring and discovery
capabilities that integrate seamlessly with the existing codebase architecture.
2025-08-16 09:33:31 -06:00

730 lines
30 KiB
Python

"""Tests for PyPI Discovery & Monitoring Tools."""
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, patch, Mock
from pypi_query_mcp.core.exceptions import InvalidPackageNameError, NetworkError, SearchError
from pypi_query_mcp.tools.discovery import (
DiscoveryCache,
get_pypi_package_recommendations,
get_pypi_trending_today,
monitor_pypi_new_releases,
search_pypi_by_maintainer,
_categorize_package,
_is_package_maintainer,
_discovery_cache,
)
class TestDiscoveryCache:
"""Test the DiscoveryCache functionality."""
def test_cache_basic_operations(self):
"""Test basic cache get/set operations."""
cache = DiscoveryCache(default_ttl=60)
# Test empty cache
assert cache.get("nonexistent") is None
# Test set and get
test_data = {"test": "value"}
cache.set("test_key", test_data)
assert cache.get("test_key") == test_data
# Test clear
cache.clear()
assert cache.get("test_key") is None
def test_cache_expiration(self):
"""Test cache expiration functionality."""
cache = DiscoveryCache(default_ttl=1) # 1 second TTL
test_data = {"test": "value"}
cache.set("test_key", test_data)
# Should be available immediately
assert cache.get("test_key") == test_data
# Mock time to simulate expiration
with patch("time.time", return_value=1000000):
cache.set("test_key", test_data)
with patch("time.time", return_value=1000002): # 2 seconds later
assert cache.get("test_key") is None
def test_cache_custom_ttl(self):
"""Test cache with custom TTL."""
cache = DiscoveryCache(default_ttl=60)
test_data = {"test": "value"}
cache.set("test_key", test_data, ttl=120) # Custom 2-minute TTL
# Should still be available after default TTL would expire
with patch("time.time", return_value=1000000):
cache.set("test_key", test_data, ttl=120)
with patch("time.time", return_value=1000060): # 1 minute later
assert cache.get("test_key") == test_data
with patch("time.time", return_value=1000130): # 2+ minutes later
assert cache.get("test_key") is None
class TestMonitorPyPINewReleases:
"""Test the monitor_pypi_new_releases function."""
@pytest.mark.asyncio
async def test_monitor_basic_functionality(self):
"""Test basic monitoring functionality."""
mock_releases = [
{
"name": "test-package",
"version": "1.0.0",
"release_time": "2023-01-01T12:00:00Z",
"description": "Test package",
"link": "https://pypi.org/project/test-package/",
}
]
mock_package_info = {
"info": {
"name": "test-package",
"version": "1.0.0",
"summary": "A test package",
"author": "Test Author",
"license": "MIT",
"home_page": "https://example.com",
"keywords": "test, package",
"requires_python": ">=3.8",
"project_urls": {},
"classifiers": ["Topic :: Software Development"],
}
}
with patch("pypi_query_mcp.tools.discovery._fetch_recent_releases_from_rss") as mock_fetch:
mock_fetch.return_value = mock_releases
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["software-development"]
result = await monitor_pypi_new_releases(hours=24)
assert "new_releases" in result
assert result["total_releases_found"] == 1
assert result["monitoring_period_hours"] == 24
assert len(result["new_releases"]) == 1
release = result["new_releases"][0]
assert release["name"] == "test-package"
assert release["summary"] == "A test package"
assert "categories" in release
@pytest.mark.asyncio
async def test_monitor_with_filters(self):
"""Test monitoring with various filters."""
mock_releases = [
{
"name": "web-package",
"version": "1.0.0",
"release_time": "2023-01-01T12:00:00Z",
"description": "Web framework",
"link": "https://pypi.org/project/web-package/",
},
{
"name": "data-package",
"version": "2.0.0",
"release_time": "2023-01-01T13:00:00Z",
"description": "Data science package",
"link": "https://pypi.org/project/data-package/",
}
]
with patch("pypi_query_mcp.tools.discovery._fetch_recent_releases_from_rss") as mock_fetch:
mock_fetch.return_value = mock_releases
with patch("pypi_query_mcp.tools.discovery.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 == "web-package":
return {
"info": {
"name": "web-package",
"author": "Web Author",
"summary": "Web framework",
"license": "MIT",
}
}
elif package_name == "data-package":
return {
"info": {
"name": "data-package",
"author": "Data Author",
"summary": "Data science package",
"license": "Apache",
}
}
mock_client.get_package_info.side_effect = mock_get_package_info
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
def mock_categorize_func(info):
if "web" in info.get("summary", "").lower():
return ["web"]
elif "data" in info.get("summary", "").lower():
return ["data-science"]
return ["general"]
mock_categorize.side_effect = mock_categorize_func
# Test category filtering
result = await monitor_pypi_new_releases(
categories=["web"],
hours=24
)
assert result["total_releases_found"] == 1
assert result["new_releases"][0]["name"] == "web-package"
# Test maintainer filtering
result = await monitor_pypi_new_releases(
maintainer_filter="Web Author",
hours=24
)
assert result["total_releases_found"] == 1
assert result["new_releases"][0]["name"] == "web-package"
@pytest.mark.asyncio
async def test_monitor_cache_functionality(self):
"""Test cache functionality in monitoring."""
# Clear cache first
_discovery_cache.clear()
mock_releases = [
{
"name": "cached-package",
"version": "1.0.0",
"release_time": "2023-01-01T12:00:00Z",
"description": "Cached package",
"link": "https://pypi.org/project/cached-package/",
}
]
with patch("pypi_query_mcp.tools.discovery._fetch_recent_releases_from_rss") as mock_fetch:
mock_fetch.return_value = mock_releases
with patch("pypi_query_mcp.tools.discovery.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 = {
"info": {
"name": "cached-package",
"summary": "Cached package",
"author": "Cache Author",
}
}
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["general"]
# First call should fetch data
result1 = await monitor_pypi_new_releases(hours=24, cache_ttl=300)
assert mock_fetch.call_count == 1
# Second call with same parameters should use cache
result2 = await monitor_pypi_new_releases(hours=24, cache_ttl=300)
assert mock_fetch.call_count == 1 # Should not increase
# Results should be identical
assert result1["timestamp"] == result2["timestamp"]
@pytest.mark.asyncio
async def test_monitor_error_handling(self):
"""Test error handling in monitoring."""
with patch("pypi_query_mcp.tools.discovery._fetch_recent_releases_from_rss") as mock_fetch:
mock_fetch.side_effect = Exception("Network error")
with pytest.raises(NetworkError):
await monitor_pypi_new_releases(hours=24)
class TestGetPyPITrendingToday:
"""Test the get_pypi_trending_today function."""
@pytest.mark.asyncio
async def test_trending_basic_functionality(self):
"""Test basic trending analysis."""
mock_releases_result = {
"new_releases": [
{
"name": "trending-package",
"version": "1.0.0",
"summary": "Trending package",
"categories": ["web"],
"release_time": "2023-01-01T12:00:00Z",
}
]
}
mock_trending_result = {
"trending_packages": [
{
"package": "popular-package",
"downloads": {"last_day": 10000},
"summary": "Popular package",
}
]
}
with patch("pypi_query_mcp.tools.discovery.monitor_pypi_new_releases") as mock_monitor:
mock_monitor.return_value = mock_releases_result
with patch("pypi_query_mcp.tools.search.get_trending_packages") as mock_trending:
mock_trending.return_value = mock_trending_result
with patch("pypi_query_mcp.tools.discovery._enhance_trending_analysis") as mock_enhance:
mock_enhance.return_value = [
{
"name": "trending-package",
"trending_score": 10.0,
"trending_reason": "new_release",
},
{
"name": "popular-package",
"trending_score": 8.0,
"trending_reason": "download_surge",
}
]
result = await get_pypi_trending_today(
category="web",
limit=10
)
assert "trending_today" in result
assert result["total_trending"] == 2
assert result["category"] == "web"
assert len(result["trending_today"]) == 2
@pytest.mark.asyncio
async def test_trending_with_filters(self):
"""Test trending analysis with filters."""
with patch("pypi_query_mcp.tools.discovery.monitor_pypi_new_releases") as mock_monitor:
mock_monitor.return_value = {"new_releases": []}
with patch("pypi_query_mcp.tools.search.get_trending_packages") as mock_trending:
mock_trending.return_value = {"trending_packages": []}
with patch("pypi_query_mcp.tools.discovery._enhance_trending_analysis") as mock_enhance:
mock_enhance.return_value = []
result = await get_pypi_trending_today(
category="ai",
min_downloads=5000,
limit=20,
include_new_packages=False,
trending_threshold=2.0
)
assert result["category"] == "ai"
assert result["filters_applied"]["min_downloads"] == 5000
assert result["filters_applied"]["trending_threshold"] == 2.0
assert not result["filters_applied"]["include_new_packages"]
@pytest.mark.asyncio
async def test_trending_error_handling(self):
"""Test error handling in trending analysis."""
with patch("pypi_query_mcp.tools.discovery.monitor_pypi_new_releases") as mock_monitor:
mock_monitor.side_effect = Exception("Monitoring error")
with pytest.raises(SearchError):
await get_pypi_trending_today()
class TestSearchPyPIByMaintainer:
"""Test the search_pypi_by_maintainer function."""
@pytest.mark.asyncio
async def test_search_by_maintainer_basic(self):
"""Test basic maintainer search functionality."""
mock_search_results = {
"packages": [
{
"name": "maintainer-package-1",
"summary": "First package",
},
{
"name": "maintainer-package-2",
"summary": "Second package",
}
]
}
mock_package_info = {
"info": {
"name": "maintainer-package-1",
"version": "1.0.0",
"summary": "First package",
"author": "Test Maintainer",
"author_email": "test@example.com",
"license": "MIT",
"keywords": "test",
"classifiers": [],
"requires_python": ">=3.8",
}
}
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
mock_search.return_value = mock_search_results
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._is_package_maintainer") as mock_is_maintainer:
mock_is_maintainer.return_value = True
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["development"]
result = await search_pypi_by_maintainer(
maintainer="Test Maintainer",
sort_by="popularity"
)
assert result["maintainer"] == "Test Maintainer"
assert result["total_packages"] == 1
assert len(result["packages"]) == 1
assert "portfolio_analysis" in result
assert "maintainer_profile" in result
@pytest.mark.asyncio
async def test_search_by_maintainer_invalid_input(self):
"""Test maintainer search with invalid input."""
with pytest.raises(InvalidPackageNameError):
await search_pypi_by_maintainer("")
with pytest.raises(InvalidPackageNameError):
await search_pypi_by_maintainer(" ")
@pytest.mark.asyncio
async def test_search_by_maintainer_with_stats(self):
"""Test maintainer search with download statistics."""
mock_search_results = {"packages": [{"name": "stats-package"}]}
mock_package_info = {
"info": {
"name": "stats-package",
"version": "1.0.0",
"author": "Stats Maintainer",
"summary": "Package with stats",
}
}
mock_stats = {
"recent_downloads": {
"last_month": 50000,
"last_week": 12000,
"last_day": 2000,
}
}
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
mock_search.return_value = mock_search_results
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._is_package_maintainer") as mock_is_maintainer:
mock_is_maintainer.return_value = True
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["general"]
with patch("pypi_query_mcp.tools.download_stats.get_package_download_stats") as mock_get_stats:
mock_get_stats.return_value = mock_stats
result = await search_pypi_by_maintainer(
maintainer="Stats Maintainer",
include_stats=True
)
assert result["total_packages"] == 1
package = result["packages"][0]
assert "download_stats" in package
assert package["download_stats"]["last_month"] == 50000
@pytest.mark.asyncio
async def test_search_by_maintainer_error_handling(self):
"""Test error handling in maintainer search."""
with patch("pypi_query_mcp.tools.search.search_packages") as mock_search:
mock_search.side_effect = Exception("Search error")
with pytest.raises(SearchError):
await search_pypi_by_maintainer("Error Maintainer")
class TestGetPyPIPackageRecommendations:
"""Test the get_pypi_package_recommendations function."""
@pytest.mark.asyncio
async def test_recommendations_basic_functionality(self):
"""Test basic recommendation functionality."""
mock_package_info = {
"info": {
"name": "base-package",
"version": "1.0.0",
"summary": "Base package for recommendations",
"keywords": "test, recommendations",
"classifiers": ["Topic :: Software Development"],
}
}
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._find_similar_packages") as mock_similar:
mock_similar.return_value = [
{
"name": "similar-package",
"type": "similar",
"confidence": 0.8,
"reason": "Similar functionality",
}
]
with patch("pypi_query_mcp.tools.discovery._enhance_recommendations") as mock_enhance:
mock_enhance.return_value = [
{
"name": "similar-package",
"type": "similar",
"confidence": 0.8,
"summary": "Similar package",
"categories": ["development"],
}
]
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["development"]
result = await get_pypi_package_recommendations(
package_name="base-package",
recommendation_type="similar"
)
assert result["base_package"]["name"] == "base-package"
assert result["total_recommendations"] == 1
assert result["recommendation_type"] == "similar"
assert len(result["recommendations"]) == 1
@pytest.mark.asyncio
async def test_recommendations_different_types(self):
"""Test different recommendation types."""
mock_package_info = {
"info": {
"name": "test-package",
"version": "1.0.0",
"summary": "Test package",
}
}
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._find_complementary_packages") as mock_complementary:
mock_complementary.return_value = [
{
"name": "complementary-package",
"type": "complementary",
"confidence": 0.9,
}
]
with patch("pypi_query_mcp.tools.discovery._enhance_recommendations") as mock_enhance:
mock_enhance.return_value = [
{
"name": "complementary-package",
"type": "complementary",
"confidence": 0.9,
}
]
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["general"]
result = await get_pypi_package_recommendations(
package_name="test-package",
recommendation_type="complementary"
)
assert result["recommendation_type"] == "complementary"
assert result["total_recommendations"] == 1
@pytest.mark.asyncio
async def test_recommendations_with_user_context(self):
"""Test recommendations with user context."""
mock_package_info = {
"info": {
"name": "context-package",
"version": "1.0.0",
"summary": "Package with context",
}
}
user_context = {
"experience_level": "beginner",
"use_case": "web development",
}
with patch("pypi_query_mcp.tools.discovery.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_info
with patch("pypi_query_mcp.tools.discovery._find_similar_packages") as mock_similar:
mock_similar.return_value = []
with patch("pypi_query_mcp.tools.discovery._enhance_recommendations") as mock_enhance:
mock_enhance.return_value = []
with patch("pypi_query_mcp.tools.discovery._categorize_package") as mock_categorize:
mock_categorize.return_value = ["web"]
result = await get_pypi_package_recommendations(
package_name="context-package",
user_context=user_context
)
assert result["parameters"]["user_context"] == user_context
assert result["algorithm_insights"]["personalization_applied"] == True
@pytest.mark.asyncio
async def test_recommendations_invalid_input(self):
"""Test recommendations with invalid input."""
with pytest.raises(InvalidPackageNameError):
await get_pypi_package_recommendations("")
with pytest.raises(InvalidPackageNameError):
await get_pypi_package_recommendations(" ")
@pytest.mark.asyncio
async def test_recommendations_error_handling(self):
"""Test error handling in recommendations."""
with patch("pypi_query_mcp.tools.discovery.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 = Exception("Package error")
with pytest.raises(SearchError):
await get_pypi_package_recommendations("error-package")
class TestHelperFunctions:
"""Test helper functions used by discovery tools."""
def test_categorize_package(self):
"""Test package categorization."""
# Test with classifiers
package_info = {
"summary": "Web framework for Python",
"description": "A micro web framework",
"keywords": "web, framework, api",
"classifiers": [
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Topic :: Software Development :: Libraries :: Python Modules"
],
}
with patch("pypi_query_mcp.tools.discovery._categorize_package", return_value=["web", "internet"]):
categories = _categorize_package(package_info)
assert "web" in categories
def test_is_package_maintainer(self):
"""Test maintainer checking functionality."""
package_info = {
"author": "John Doe",
"author_email": "john@example.com",
"maintainer": "Jane Smith",
"maintainer_email": "jane@example.com",
}
# Test author match
assert _is_package_maintainer(package_info, "John Doe", False) == True
assert _is_package_maintainer(package_info, "john doe", False) == True
# Test maintainer match
assert _is_package_maintainer(package_info, "Jane Smith", False) == True
# Test no match
assert _is_package_maintainer(package_info, "Bob Wilson", False) == False
# Test email match (when enabled)
assert _is_package_maintainer(package_info, "john@example.com", True) == True
assert _is_package_maintainer(package_info, "john@example.com", False) == False
@pytest.fixture
def mock_rss_response():
"""Mock RSS response for testing."""
return '''<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>PyPI Recent Updates</title>
<item>
<title>test-package 1.0.0</title>
<description>Test package description</description>
<link>https://pypi.org/project/test-package/</link>
<pubDate>Mon, 01 Jan 2023 12:00:00 GMT</pubDate>
</item>
</channel>
</rss>'''
class TestIntegration:
"""Integration tests for discovery tools."""
@pytest.mark.asyncio
async def test_full_workflow_monitoring_to_recommendations(self):
"""Test full workflow from monitoring to recommendations."""
# This would be a more complex integration test
# that combines multiple functions in a realistic workflow
pass
@pytest.mark.asyncio
async def test_cache_consistency_across_functions(self):
"""Test cache consistency across different discovery functions."""
# Clear cache first
_discovery_cache.clear()
# Test that cache is properly shared between functions
with patch("pypi_query_mcp.tools.discovery._fetch_recent_releases_from_rss") as mock_fetch:
mock_fetch.return_value = []
# First call should populate cache
await monitor_pypi_new_releases(hours=24, cache_ttl=300)
assert mock_fetch.call_count == 1
# Second call should use cache
await monitor_pypi_new_releases(hours=24, cache_ttl=300)
assert mock_fetch.call_count == 1 # Should not increase
def test_error_propagation(self):
"""Test that errors are properly propagated and handled."""
# Test various error scenarios and ensure they're handled consistently
pass
# Additional test classes for edge cases and performance testing could be added here