Implement PyPI Community & Social Tools

- Add get_pypi_package_reviews: Aggregates community feedback from GitHub issues, Stack Overflow, and social media with sentiment analysis
- Add manage_pypi_package_discussions: Future-ready discussion management with GitHub Discussions integration
- Add get_pypi_maintainer_contacts: Privacy-respecting maintainer contact discovery with communication guidelines
- Integrate all tools with MCP server endpoints and comprehensive error handling
- Add extensive test coverage for all community functionality
- Follow existing code patterns and async/await best practices
- Include future-ready implementations for when PyPI adds native community features
This commit is contained in:
Ryan Malloy 2025-08-16 09:53:32 -06:00
parent f79144d710
commit 05dd346f45
4 changed files with 2567 additions and 0 deletions

View File

@ -52,6 +52,9 @@ from .tools import (
set_package_visibility,
update_package_metadata,
upload_package_to_pypi,
get_pypi_package_reviews,
manage_pypi_package_discussions,
get_pypi_maintainer_contacts,
)
from .tools.discovery import (
get_pypi_package_recommendations,
@ -2370,6 +2373,196 @@ async def get_pypi_package_recommendations_tool(
}
@mcp.tool()
async def get_pypi_package_reviews_tool(
package_name: str,
include_ratings: bool = True,
include_community_feedback: bool = True,
sentiment_analysis: bool = False,
max_reviews: int = 50,
) -> dict[str, Any]:
"""Get community reviews and feedback for a PyPI package.
This tool aggregates community feedback from various sources including
GitHub discussions, issues, Stack Overflow mentions, and social media to
provide comprehensive community sentiment analysis.
Note: This is a future-ready implementation as PyPI doesn't currently have
a native review system. The function prepares for when such features become
available while providing useful community sentiment analysis from existing sources.
Args:
package_name: Name of the package to get reviews for
include_ratings: Whether to include numerical ratings (when available)
include_community_feedback: Whether to include textual feedback analysis
sentiment_analysis: Whether to perform sentiment analysis on feedback
max_reviews: Maximum number of reviews to return
Returns:
Dictionary containing review and feedback information including:
- Community sentiment and ratings
- Feedback from GitHub issues and discussions
- Social media mentions and sentiment
- Quality indicators and community health metrics
- Future-ready structure for native PyPI reviews
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Getting community reviews for package: {package_name}")
result = await get_pypi_package_reviews(
package_name=package_name,
include_ratings=include_ratings,
include_community_feedback=include_community_feedback,
sentiment_analysis=sentiment_analysis,
max_reviews=max_reviews,
)
logger.info(f"Successfully retrieved community reviews for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error getting reviews for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error getting reviews for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
}
@mcp.tool()
async def manage_pypi_package_discussions_tool(
package_name: str,
action: str = "get_status",
discussion_settings: dict[str, Any] | None = None,
moderator_controls: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Manage and interact with PyPI package discussions.
This tool provides management capabilities for package discussions,
including enabling/disabling discussions, setting moderation policies,
and retrieving discussion status and metrics.
Note: This is a future-ready implementation as PyPI doesn't currently have
native discussion features. The function prepares for when such features
become available while providing integration with existing discussion platforms.
Args:
package_name: Name of the package to manage discussions for
action: Action to perform ('get_status', 'enable', 'disable', 'configure', 'moderate')
discussion_settings: Settings for discussions (when enabling/configuring)
moderator_controls: Moderation settings and controls
Returns:
Dictionary containing discussion management results including:
- Current discussion status and settings
- Available discussion platforms and integration status
- Moderation settings and community guidelines
- Discussion metrics and engagement data
- Future-ready structure for native PyPI discussions
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Managing discussions for package: {package_name}, action: {action}")
result = await manage_pypi_package_discussions(
package_name=package_name,
action=action,
discussion_settings=discussion_settings,
moderator_controls=moderator_controls,
)
logger.info(f"Successfully managed discussions for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error managing discussions for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"action": action,
}
except Exception as e:
logger.error(f"Unexpected error managing discussions for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"action": action,
}
@mcp.tool()
async def get_pypi_maintainer_contacts_tool(
package_name: str,
contact_types: list[str] | None = None,
include_social_profiles: bool = False,
include_contribution_guidelines: bool = True,
respect_privacy_settings: bool = True,
) -> dict[str, Any]:
"""Get contact information and communication channels for package maintainers.
This tool retrieves publicly available contact information for package
maintainers while respecting privacy settings and providing appropriate
communication channels for different types of inquiries.
Args:
package_name: Name of the package to get maintainer contacts for
contact_types: Types of contact info to retrieve ('email', 'github', 'social', 'support')
include_social_profiles: Whether to include social media profiles
include_contribution_guidelines: Whether to include contribution guidelines
respect_privacy_settings: Whether to respect maintainer privacy preferences
Returns:
Dictionary containing maintainer contact information including:
- Publicly available contact methods
- Communication preferences and guidelines
- Support channels and community resources
- Privacy-respecting contact recommendations
- Contribution guidelines and community standards
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Getting maintainer contacts for package: {package_name}")
result = await get_pypi_maintainer_contacts(
package_name=package_name,
contact_types=contact_types,
include_social_profiles=include_social_profiles,
include_contribution_guidelines=include_contribution_guidelines,
respect_privacy_settings=respect_privacy_settings,
)
logger.info(f"Successfully retrieved maintainer contacts for package: {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
logger.error(f"Error getting maintainer contacts for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error getting maintainer contacts for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
}
@click.command()
@click.option(
"--log-level",

View File

@ -59,6 +59,11 @@ from .workflow import (
preview_pypi_package_page,
validate_pypi_package_name,
)
from .community import (
get_pypi_package_reviews,
manage_pypi_package_discussions,
get_pypi_maintainer_contacts,
)
__all__ = [
"query_package_info",
@ -98,4 +103,7 @@ __all__ = [
"preview_pypi_package_page",
"check_pypi_upload_requirements",
"get_pypi_build_logs",
"get_pypi_package_reviews",
"manage_pypi_package_discussions",
"get_pypi_maintainer_contacts",
]

File diff suppressed because it is too large Load Diff

985
tests/test_community.py Normal file
View File

@ -0,0 +1,985 @@
"""Tests for PyPI community and social tools functionality."""
import json
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
import pytest
from pypi_query_mcp.core.exceptions import InvalidPackageNameError, PackageNotFoundError, NetworkError
from pypi_query_mcp.tools.community import (
get_pypi_package_reviews,
manage_pypi_package_discussions,
get_pypi_maintainer_contacts,
_analyze_github_community_sentiment,
_check_stackoverflow_mentions,
_analyze_pypi_downloads_as_quality_indicator,
_get_community_health_metrics,
_calculate_community_score,
_generate_community_insights,
_extract_contact_info_from_metadata,
_find_github_repository,
_parse_github_url,
_analyze_issue_sentiment,
_analyze_stackoverflow_sentiment,
)
class TestGetPyPIPackageReviews:
"""Test community reviews and feedback functionality."""
@pytest.fixture
def mock_package_data(self):
"""Mock package data for testing."""
return {
"info": {
"name": "test-package",
"version": "1.0.0",
"summary": "A test package for community analysis",
"description": "A comprehensive test package with detailed description for community testing",
"keywords": "test, community, package",
"classifiers": [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Topic :: Software Development :: Libraries",
],
"license": "MIT",
"author": "Test Author",
"home_page": "https://example.com",
"project_urls": {
"Documentation": "https://docs.example.com",
"Repository": "https://github.com/test/test-package",
"Bug Reports": "https://github.com/test/test-package/issues",
},
}
}
@pytest.fixture
def mock_github_sentiment(self):
"""Mock GitHub sentiment analysis data."""
return {
"repository": "https://github.com/test/test-package",
"sentiment_analysis": {
"overall_sentiment_score": 75.5,
"issues_analyzed": 20,
"positive_indicators": 15,
"negative_indicators": 5,
"sentiment_factors": {
"closed_issues": 12,
"open_issues": 8,
"enhancement_requests": 5,
"bug_reports": 3,
},
},
"repository_stats": {
"stargazers_count": 150,
"forks_count": 25,
"open_issues_count": 8,
},
"issues_analyzed": 20,
"analysis_timestamp": datetime.now().isoformat(),
}
@pytest.fixture
def mock_stackoverflow_data(self):
"""Mock Stack Overflow mentions data."""
return {
"questions_found": 5,
"sentiment_analysis": {
"overall_sentiment_score": 65.0,
"questions_analyzed": 5,
"positive_indicators": 3,
"negative_indicators": 2,
"question_characteristics": {
"answered_questions": 4,
"unanswered_questions": 1,
"average_score": 2.4,
},
},
"search_timestamp": datetime.now().isoformat(),
"data_source": "Stack Overflow API",
}
@pytest.fixture
def mock_quality_indicators(self):
"""Mock quality indicators data."""
return {
"download_stats": {
"last_month": 50000,
"last_week": 12000,
"last_day": 2000,
},
"adoption_level": "moderate",
"quality_indicator_score": 50.0,
"analysis_timestamp": datetime.now().isoformat(),
}
@pytest.fixture
def mock_community_health(self):
"""Mock community health metrics."""
return {
"github_community_health": {
"health_percentage": 85,
"documentation": {"exists": True},
"contributing": {"exists": True},
"code_of_conduct": {"exists": True},
"license": {"exists": True},
"readme": {"exists": True},
},
"has_repository": True,
"repository_url": "https://github.com/test/test-package",
}
async def test_get_pypi_package_reviews_success(
self,
mock_package_data,
mock_github_sentiment,
mock_stackoverflow_data,
mock_quality_indicators,
mock_community_health
):
"""Test successful retrieval of package reviews."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_reviews") as mock_metadata, \
patch("pypi_query_mcp.tools.community._analyze_github_community_sentiment") as mock_github, \
patch("pypi_query_mcp.tools.community._check_stackoverflow_mentions") as mock_stackoverflow, \
patch("pypi_query_mcp.tools.community._analyze_pypi_downloads_as_quality_indicator") as mock_quality, \
patch("pypi_query_mcp.tools.community._get_community_health_metrics") as mock_health:
mock_metadata.return_value = mock_package_data["info"]
mock_github.return_value = mock_github_sentiment
mock_stackoverflow.return_value = mock_stackoverflow_data
mock_quality.return_value = mock_quality_indicators
mock_health.return_value = mock_community_health
result = await get_pypi_package_reviews(
package_name="test-package",
include_ratings=True,
include_community_feedback=True,
sentiment_analysis=True,
max_reviews=50
)
assert result["package"] == "test-package"
assert "community_score" in result
assert "metadata" in result
assert "community_health" in result
assert "quality_indicators" in result
assert "insights" in result
assert "review_system_status" in result
assert "github_community_feedback" in result
assert "stackoverflow_mentions" in result
assert "sentiment_analysis" in result
assert "ratings" in result
# Check community score structure
community_score = result["community_score"]
assert "overall_score" in community_score
assert "community_status" in community_score
assert "score_components" in community_score
# Check review system status
review_status = result["review_system_status"]
assert review_status["native_pypi_reviews"] == "not_available"
assert review_status["future_ready"] is True
async def test_get_pypi_package_reviews_invalid_package_name(self):
"""Test handling of invalid package name."""
with pytest.raises(InvalidPackageNameError):
await get_pypi_package_reviews("")
with pytest.raises(InvalidPackageNameError):
await get_pypi_package_reviews(" ")
async def test_get_pypi_package_reviews_minimal_options(self, mock_package_data):
"""Test reviews with minimal options enabled."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_reviews") as mock_metadata, \
patch("pypi_query_mcp.tools.community._analyze_pypi_downloads_as_quality_indicator") as mock_quality, \
patch("pypi_query_mcp.tools.community._get_community_health_metrics") as mock_health:
mock_metadata.return_value = mock_package_data["info"]
mock_quality.return_value = {"quality_indicator_score": 30}
mock_health.return_value = {"has_repository": False}
result = await get_pypi_package_reviews(
package_name="test-package",
include_ratings=False,
include_community_feedback=False,
sentiment_analysis=False
)
assert result["package"] == "test-package"
assert "github_community_feedback" not in result
assert "stackoverflow_mentions" not in result
assert "sentiment_analysis" not in result
assert "ratings" not in result
async def test_get_pypi_package_reviews_network_error(self):
"""Test handling of network errors."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_reviews", side_effect=NetworkError("Network error")):
with pytest.raises(NetworkError):
await get_pypi_package_reviews("test-package")
class TestManagePyPIPackageDiscussions:
"""Test package discussions management functionality."""
@pytest.fixture
def mock_package_data(self):
"""Mock package data for discussions testing."""
return {
"info": {
"name": "test-package",
"project_urls": {
"Repository": "https://github.com/test/test-package",
},
}
}
@pytest.fixture
def mock_discussion_status(self):
"""Mock current discussion status."""
return {
"github_discussions": {
"enabled": False,
"reason": "requires_github_api_integration",
"repository": "https://github.com/test/test-package",
},
"community_platforms": {
"discord": {"available": False},
"reddit": {"available": False},
"forums": {"available": False},
},
"native_pypi_discussions": {
"available": False,
"note": "PyPI does not currently support native discussions",
},
}
async def test_manage_discussions_get_status(self, mock_package_data, mock_discussion_status):
"""Test getting discussion status."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="get_status"
)
assert result["package"] == "test-package"
assert result["action_performed"] == "get_status"
assert "status" in result
assert "current_discussion_status" in result
assert "available_platforms" in result
assert "discussion_system_status" in result
# Check system status
system_status = result["discussion_system_status"]
assert system_status["native_pypi_discussions"] == "not_available"
assert system_status["future_ready"] is True
async def test_manage_discussions_enable(self, mock_package_data, mock_discussion_status):
"""Test enabling discussions."""
discussion_settings = {
"categories": ["General", "Q&A", "Ideas"],
"moderation": "manual_review",
}
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="enable",
discussion_settings=discussion_settings
)
assert result["package"] == "test-package"
assert result["action_performed"] == "enable"
assert result["status"] == "configured"
assert result["action"] == "enable_discussions"
assert "settings_applied" in result
assert "next_steps" in result
async def test_manage_discussions_disable(self, mock_package_data, mock_discussion_status):
"""Test disabling discussions."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="disable"
)
assert result["package"] == "test-package"
assert result["action_performed"] == "disable"
assert result["status"] == "configured"
assert result["action"] == "disable_discussions"
assert "next_steps" in result
async def test_manage_discussions_configure(self, mock_package_data, mock_discussion_status):
"""Test configuring discussions."""
discussion_settings = {
"categories": ["General", "Q&A", "Ideas", "Show and Tell"],
"moderation": "community_moderation",
"notifications": ["email_notifications", "web_notifications"],
}
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="configure",
discussion_settings=discussion_settings
)
assert result["package"] == "test-package"
assert result["action_performed"] == "configure"
assert result["status"] == "configured"
assert result["action"] == "configure_discussions"
assert "configuration_options" in result
async def test_manage_discussions_moderate(self, mock_package_data, mock_discussion_status):
"""Test moderating discussions."""
moderator_controls = {
"content_filtering": True,
"auto_moderation": True,
"moderator_roles": ["owner", "maintainer"],
}
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="moderate",
moderator_controls=moderator_controls
)
assert result["package"] == "test-package"
assert result["action_performed"] == "moderate"
assert result["status"] == "moderation_configured"
assert result["action"] == "moderate_discussions"
assert "moderation_features" in result
async def test_manage_discussions_get_metrics(self, mock_package_data, mock_discussion_status):
"""Test getting discussion metrics."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_discussions") as mock_metadata, \
patch("pypi_query_mcp.tools.community._get_current_discussion_status") as mock_status:
mock_metadata.return_value = mock_package_data
mock_status.return_value = mock_discussion_status
result = await manage_pypi_package_discussions(
package_name="test-package",
action="get_metrics"
)
assert result["package"] == "test-package"
assert result["action_performed"] == "get_metrics"
assert result["status"] == "metrics_retrieved"
assert "github_metrics" in result
assert "overall_engagement" in result
async def test_manage_discussions_invalid_action(self):
"""Test handling of invalid action."""
with pytest.raises(InvalidPackageNameError):
await manage_pypi_package_discussions(
package_name="test-package",
action="invalid_action"
)
async def test_manage_discussions_invalid_package_name(self):
"""Test handling of invalid package name."""
with pytest.raises(InvalidPackageNameError):
await manage_pypi_package_discussions("")
with pytest.raises(InvalidPackageNameError):
await manage_pypi_package_discussions(" ")
class TestGetPyPIMaintainerContacts:
"""Test maintainer contact information functionality."""
@pytest.fixture
def mock_package_metadata(self):
"""Mock package metadata for contact testing."""
return {
"name": "test-package",
"author": "Test Author",
"author_email": "author@example.com",
"maintainer": "Test Maintainer",
"maintainer_email": "maintainer@example.com",
"home_page": "https://example.com",
"project_urls": {
"Documentation": "https://docs.example.com",
"Repository": "https://github.com/test/test-package",
"Bug Reports": "https://github.com/test/test-package/issues",
"Support": "https://support.example.com",
},
}
@pytest.fixture
def mock_github_info(self):
"""Mock GitHub maintainer information."""
return {
"repository": "https://github.com/test/test-package",
"owner": "test",
"repository_data": {
"owner": {
"login": "test",
"type": "User",
"html_url": "https://github.com/test",
},
"has_pages": True,
"default_branch": "main",
},
"contributors": [
{
"login": "test",
"contributions": 150,
"html_url": "https://github.com/test",
},
{
"login": "contributor1",
"contributions": 25,
"html_url": "https://github.com/contributor1",
},
],
"primary_maintainer": {
"login": "test",
"type": "User",
"html_url": "https://github.com/test",
},
}
@pytest.fixture
def mock_support_channels(self):
"""Mock support channels information."""
return {
"issue_tracker": "https://github.com/test/test-package/issues",
"documentation": "https://test.github.io/test-package/",
"community_forum": None,
"chat_channels": [],
}
@pytest.fixture
def mock_community_channels(self):
"""Mock community channels information."""
return {
"github_discussions": "https://github.com/test/test-package/discussions",
"stackoverflow_tag": "https://stackoverflow.com/questions/tagged/test-package",
"reddit_community": None,
"discord_server": None,
}
@pytest.fixture
def mock_contribution_info(self):
"""Mock contribution guidelines information."""
return {
"repository": "https://github.com/test/test-package",
"contribution_files": {
"CONTRIBUTING.md": True,
"CODE_OF_CONDUCT.md": True,
"SECURITY.md": False,
},
"guidelines_available": True,
}
async def test_get_maintainer_contacts_success(
self,
mock_package_metadata,
mock_github_info,
mock_support_channels,
mock_community_channels,
mock_contribution_info
):
"""Test successful retrieval of maintainer contacts."""
contact_types = ["github", "support", "community"]
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_contacts") as mock_metadata, \
patch("pypi_query_mcp.tools.community._analyze_github_maintainer_info") as mock_github, \
patch("pypi_query_mcp.tools.community._get_support_channels") as mock_support, \
patch("pypi_query_mcp.tools.community._get_community_channels") as mock_community, \
patch("pypi_query_mcp.tools.community._get_contribution_guidelines") as mock_contrib:
mock_metadata.return_value = mock_package_metadata
mock_github.return_value = mock_github_info
mock_support.return_value = mock_support_channels
mock_community.return_value = mock_community_channels
mock_contrib.return_value = mock_contribution_info
result = await get_pypi_maintainer_contacts(
package_name="test-package",
contact_types=contact_types,
include_social_profiles=True,
include_contribution_guidelines=True,
respect_privacy_settings=True
)
assert result["package"] == "test-package"
assert "contact_information" in result
assert "accessibility_assessment" in result
assert "contact_recommendations" in result
assert "privacy_compliance" in result
assert "github_information" in result
assert "support_channels" in result
assert "community_channels" in result
assert "contribution_guidelines" in result
assert "social_profiles" in result
assert "communication_guidelines" in result
# Check privacy compliance
privacy = result["privacy_compliance"]
assert privacy["respects_privacy_settings"] is True
assert privacy["data_sources"] == "Publicly available information only"
async def test_get_maintainer_contacts_email_included(self, mock_package_metadata):
"""Test contacts with email included and privacy disabled."""
contact_types = ["email", "github"]
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_contacts") as mock_metadata, \
patch("pypi_query_mcp.tools.community._analyze_github_maintainer_info") as mock_github:
mock_metadata.return_value = mock_package_metadata
mock_github.return_value = {"status": "no_github_repository"}
result = await get_pypi_maintainer_contacts(
package_name="test-package",
contact_types=contact_types,
respect_privacy_settings=False
)
contact_info = result["contact_information"]
assert "available_contacts" in contact_info
# When privacy is disabled, emails should be included
if not contact_info["privacy_compliant"]:
# This would include emails if privacy is disabled
pass
async def test_get_maintainer_contacts_privacy_enabled(self, mock_package_metadata):
"""Test contacts with privacy settings enabled."""
contact_types = ["email", "github"]
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_contacts") as mock_metadata:
mock_metadata.return_value = mock_package_metadata
result = await get_pypi_maintainer_contacts(
package_name="test-package",
contact_types=contact_types,
respect_privacy_settings=True
)
contact_info = result["contact_information"]
assert contact_info["privacy_compliant"] is True
# With privacy enabled, emails should be hidden
if "email_note" in contact_info.get("available_contacts", {}):
assert "hidden due to privacy settings" in contact_info["available_contacts"]["email_note"]
async def test_get_maintainer_contacts_minimal_options(self, mock_package_metadata):
"""Test contacts with minimal options."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_contacts") as mock_metadata:
mock_metadata.return_value = mock_package_metadata
result = await get_pypi_maintainer_contacts(
package_name="test-package",
contact_types=["support"],
include_social_profiles=False,
include_contribution_guidelines=False
)
assert result["package"] == "test-package"
assert "contact_information" in result
assert "github_information" not in result
assert "contribution_guidelines" not in result
assert "social_profiles" not in result
async def test_get_maintainer_contacts_invalid_contact_types(self):
"""Test handling of invalid contact types."""
with pytest.raises(InvalidPackageNameError):
await get_pypi_maintainer_contacts(
package_name="test-package",
contact_types=["invalid_type"]
)
async def test_get_maintainer_contacts_invalid_package_name(self):
"""Test handling of invalid package name."""
with pytest.raises(InvalidPackageNameError):
await get_pypi_maintainer_contacts("")
with pytest.raises(InvalidPackageNameError):
await get_pypi_maintainer_contacts(" ")
class TestHelperFunctions:
"""Test helper functions for community tools."""
def test_parse_github_url_valid(self):
"""Test parsing valid GitHub URLs."""
test_cases = [
("https://github.com/owner/repo", {"repository_url": "https://github.com/owner/repo", "owner": "owner", "repo": "repo"}),
("https://github.com/owner/repo.git", {"repository_url": "https://github.com/owner/repo", "owner": "owner", "repo": "repo"}),
("https://github.com/owner/repo/", {"repository_url": "https://github.com/owner/repo", "owner": "owner", "repo": "repo"}),
]
for url, expected in test_cases:
result = _parse_github_url(url)
assert result == expected
def test_parse_github_url_invalid(self):
"""Test parsing invalid GitHub URLs."""
test_cases = [
"https://gitlab.com/owner/repo",
"https://github.com/owner",
"https://github.com/",
"not-a-url",
]
for url in test_cases:
result = _parse_github_url(url)
assert "status" in result or "error" in result
def test_analyze_issue_sentiment_positive(self):
"""Test analyzing positive GitHub issue sentiment."""
issues_data = {
"issues": [
{
"title": "Enhancement: Add new feature",
"state": "closed",
"labels": [{"name": "enhancement"}, {"name": "good first issue"}],
},
{
"title": "How to use this package?",
"state": "closed",
"labels": [{"name": "question"}],
},
]
}
result = _analyze_issue_sentiment(issues_data)
assert result["overall_sentiment_score"] > 50
assert result["issues_analyzed"] == 2
assert result["sentiment_factors"]["closed_issues"] == 2
assert result["sentiment_factors"]["enhancement_requests"] == 1
def test_analyze_issue_sentiment_negative(self):
"""Test analyzing negative GitHub issue sentiment."""
issues_data = {
"issues": [
{
"title": "Critical bug: Application crashes",
"state": "open",
"labels": [{"name": "bug"}, {"name": "critical"}],
},
{
"title": "Error when importing package",
"state": "open",
"labels": [{"name": "bug"}],
},
]
}
result = _analyze_issue_sentiment(issues_data)
assert result["overall_sentiment_score"] < 50
assert result["issues_analyzed"] == 2
assert result["sentiment_factors"]["open_issues"] == 2
assert result["sentiment_factors"]["bug_reports"] == 2
def test_analyze_stackoverflow_sentiment_positive(self):
"""Test analyzing positive Stack Overflow sentiment."""
questions = [
{
"title": "How to implement best practices with test-package",
"tags": ["test-package", "python"],
"score": 5,
"is_answered": True,
},
{
"title": "Tutorial: Getting started with test-package",
"tags": ["test-package", "tutorial"],
"score": 3,
"is_answered": True,
},
]
result = _analyze_stackoverflow_sentiment(questions, "test-package")
assert result["overall_sentiment_score"] > 50
assert result["questions_analyzed"] == 2
assert result["question_characteristics"]["answered_questions"] == 2
assert result["question_characteristics"]["average_score"] == 4.0
def test_analyze_stackoverflow_sentiment_negative(self):
"""Test analyzing negative Stack Overflow sentiment."""
questions = [
{
"title": "test-package not working: Error on import",
"tags": ["test-package", "error"],
"score": -1,
"is_answered": False,
},
{
"title": "Problem with test-package installation",
"tags": ["test-package", "installation"],
"score": 0,
"is_answered": False,
},
]
result = _analyze_stackoverflow_sentiment(questions, "test-package")
assert result["overall_sentiment_score"] < 50
assert result["questions_analyzed"] == 2
assert result["question_characteristics"]["unanswered_questions"] == 2
assert result["question_characteristics"]["average_score"] == -0.5
def test_calculate_community_score_excellent(self):
"""Test calculating excellent community score."""
github_sentiment = {
"sentiment_analysis": {"overall_sentiment_score": 85}
}
stackoverflow_data = {
"sentiment_analysis": {"overall_sentiment_score": 80}
}
quality_indicators = {
"quality_indicator_score": 90
}
community_health = {
"github_community_health": {"health_percentage": 95}
}
result = _calculate_community_score(
github_sentiment,
stackoverflow_data,
quality_indicators,
community_health
)
assert result["overall_score"] >= 80
assert result["community_status"] == "excellent"
assert len(result["score_components"]) > 0
def test_calculate_community_score_poor(self):
"""Test calculating poor community score."""
github_sentiment = {
"sentiment_analysis": {"overall_sentiment_score": 20}
}
stackoverflow_data = {
"sentiment_analysis": {"overall_sentiment_score": 25}
}
quality_indicators = {
"quality_indicator_score": 15
}
community_health = {}
result = _calculate_community_score(
github_sentiment,
stackoverflow_data,
quality_indicators,
community_health
)
assert result["overall_score"] < 35
assert result["community_status"] == "poor"
def test_generate_community_insights_strong_community(self):
"""Test generating insights for strong community."""
github_sentiment = {
"repository_stats": {"stargazers_count": 2000}
}
stackoverflow_data = {
"questions_found": 25
}
community_score = {
"overall_score": 85
}
package_metadata = {
"name": "test-package"
}
result = _generate_community_insights(
github_sentiment,
stackoverflow_data,
community_score,
package_metadata
)
assert "key_insights" in result
assert "community_strengths" in result
assert len(result["community_strengths"]) > 0
# Should have positive insights for high score
insights_text = " ".join(result["key_insights"])
assert "strong" in insights_text.lower() or "positive" in insights_text.lower()
def test_extract_contact_info_from_metadata_with_privacy(self):
"""Test extracting contact info with privacy enabled."""
package_metadata = {
"author_email": "author@example.com",
"maintainer_email": "maintainer@example.com",
"project_urls": {
"Repository": "https://github.com/test/repo",
"Documentation": "https://docs.example.com",
"Support": "https://support.example.com",
},
"home_page": "https://example.com",
}
contact_types = ["email", "github", "support"]
result = _extract_contact_info_from_metadata(
package_metadata,
contact_types,
respect_privacy=True
)
assert result["privacy_compliant"] is True
# With privacy enabled, emails should be hidden
assert "email_note" in result["available_contacts"]
# Project URLs should still be included
assert len(result["project_urls"]) > 0
def test_extract_contact_info_from_metadata_without_privacy(self):
"""Test extracting contact info with privacy disabled."""
package_metadata = {
"author_email": "author@example.com",
"maintainer_email": "maintainer@example.com",
"project_urls": {
"Repository": "https://github.com/test/repo",
},
}
contact_types = ["email", "github"]
result = _extract_contact_info_from_metadata(
package_metadata,
contact_types,
respect_privacy=False
)
assert result["privacy_compliant"] is False
# With privacy disabled, emails should be included
assert "author_email" in result["available_contacts"]
assert "maintainer_email" in result["available_contacts"]
class TestCommunityIntegrations:
"""Test community tool integrations with external services."""
async def test_github_community_sentiment_no_repository(self):
"""Test GitHub sentiment analysis when no repository is found."""
with patch("pypi_query_mcp.tools.community._find_github_repository") as mock_find:
mock_find.return_value = {"status": "no_github_repository"}
result = await _analyze_github_community_sentiment("test-package")
assert result["status"] == "no_github_repository"
async def test_stackoverflow_mentions_api_error(self):
"""Test Stack Overflow mentions with API error."""
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get.return_value.status_code = 500
result = await _check_stackoverflow_mentions("test-package")
assert result["status"] == "api_unavailable"
assert result["questions_found"] == 0
async def test_quality_indicator_with_download_stats(self):
"""Test quality indicator calculation with download stats."""
with patch("pypi_query_mcp.tools.community.get_package_download_stats") as mock_stats:
mock_stats.return_value = {
"downloads": {
"last_month": 500000,
"last_week": 125000,
"last_day": 18000,
}
}
result = await _analyze_pypi_downloads_as_quality_indicator("test-package")
assert result["adoption_level"] == "high"
assert result["quality_indicator_score"] > 0
assert "download_stats" in result
async def test_community_health_metrics_no_repository(self):
"""Test community health metrics when no repository exists."""
with patch("pypi_query_mcp.tools.community._find_github_repository") as mock_find:
mock_find.return_value = {"status": "no_github_repository"}
result = await _get_community_health_metrics("test-package")
assert result["has_repository"] is False
assert "note" in result
@pytest.mark.asyncio
class TestAsyncBehavior:
"""Test async behavior and error handling."""
async def test_concurrent_operations_success(self):
"""Test that concurrent operations work correctly."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_reviews") as mock_meta, \
patch("pypi_query_mcp.tools.community._analyze_github_community_sentiment") as mock_github, \
patch("pypi_query_mcp.tools.community._check_stackoverflow_mentions") as mock_so, \
patch("pypi_query_mcp.tools.community._analyze_pypi_downloads_as_quality_indicator") as mock_quality, \
patch("pypi_query_mcp.tools.community._get_community_health_metrics") as mock_health:
# Set up mocks to return after small delays to test concurrency
import asyncio
async def delayed_return(value, delay=0.01):
await asyncio.sleep(delay)
return value
mock_meta.return_value = delayed_return({"name": "test-package"})
mock_github.return_value = delayed_return({"sentiment_analysis": {"overall_sentiment_score": 75}})
mock_so.return_value = delayed_return({"sentiment_analysis": {"overall_sentiment_score": 70}})
mock_quality.return_value = delayed_return({"quality_indicator_score": 80})
mock_health.return_value = delayed_return({"has_repository": True})
start_time = datetime.now()
result = await get_pypi_package_reviews("test-package")
end_time = datetime.now()
# Should complete relatively quickly due to concurrent execution
assert (end_time - start_time).total_seconds() < 1.0
assert result["package"] == "test-package"
async def test_partial_failure_handling(self):
"""Test handling when some operations fail but others succeed."""
with patch("pypi_query_mcp.tools.community._get_package_metadata_for_reviews") as mock_meta, \
patch("pypi_query_mcp.tools.community._analyze_github_community_sentiment", side_effect=Exception("GitHub error")) as mock_github, \
patch("pypi_query_mcp.tools.community._check_stackoverflow_mentions") as mock_so, \
patch("pypi_query_mcp.tools.community._analyze_pypi_downloads_as_quality_indicator") as mock_quality, \
patch("pypi_query_mcp.tools.community._get_community_health_metrics", side_effect=Exception("Health error")) as mock_health:
mock_meta.return_value = {"name": "test-package"}
mock_so.return_value = {"sentiment_analysis": {"overall_sentiment_score": 70}}
mock_quality.return_value = {"quality_indicator_score": 80}
result = await get_pypi_package_reviews("test-package")
# Should still return a result even with some failures
assert result["package"] == "test-package"
assert "community_score" in result
# Failed operations should result in empty dicts or be excluded