feat: implement comprehensive configuration management system with multi-mirror support

- Add ServerSettings class with pydantic-settings for type-safe configuration
- Support multiple PyPI mirror sources with priority-based fallback mechanism
- Implement RepositoryConfig and RepositoryManager for multi-repository support
- Add environment variable support for all configuration options
- Include private repository authentication configuration
- Add advanced dependency analysis settings (max depth, concurrency, security)
- Provide secure credential management with sensitive data masking
- Update documentation and configuration examples
- Add comprehensive test suite with 23 test cases covering all features
- Include demo script showcasing multi-mirror configuration capabilities

Configuration features:
- Primary, additional, and fallback index URLs
- Automatic duplicate URL removal with priority preservation
- Runtime configuration reloading
- Integration with repository manager for seamless multi-source queries

Signed-off-by: longhao <hal.long@outlook.com>
This commit is contained in:
longhao 2025-05-27 17:36:25 +08:00 committed by Hal
parent f27493d8d2
commit a0c507c3ff
20 changed files with 1276 additions and 165 deletions

View file

@ -4,5 +4,32 @@ This package handles configuration loading, validation, and management
for the MCP server, including private registry settings.
"""
# Configuration exports will be added as modules are implemented
__all__ = []
from .repository import (
AuthType,
RepositoryConfig,
RepositoryManager,
RepositoryType,
get_repository_manager,
reload_repository_manager,
)
from .settings import (
ServerSettings,
get_settings,
reload_settings,
update_settings,
)
__all__ = [
# Settings
"ServerSettings",
"get_settings",
"reload_settings",
"update_settings",
# Repository
"RepositoryConfig",
"RepositoryManager",
"RepositoryType",
"AuthType",
"get_repository_manager",
"reload_repository_manager",
]

View file

@ -0,0 +1,252 @@
"""Repository configuration for PyPI Query MCP Server."""
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_validator
class RepositoryType(str, Enum):
"""Repository type enumeration."""
PUBLIC = "public"
PRIVATE = "private"
class AuthType(str, Enum):
"""Authentication type enumeration."""
NONE = "none"
BASIC = "basic"
TOKEN = "token"
class RepositoryConfig(BaseModel):
"""Configuration for a PyPI repository."""
name: str = Field(description="Repository name")
url: str = Field(description="Repository URL")
type: RepositoryType = Field(description="Repository type")
priority: int = Field(
default=100, description="Repository priority (lower = higher priority)"
)
# Authentication settings
auth_type: AuthType = Field(
default=AuthType.NONE, description="Authentication type"
)
username: str | None = Field(
default=None, description="Username for authentication"
)
password: str | None = Field(
default=None, description="Password for authentication"
)
token: str | None = Field(default=None, description="Token for authentication")
# Connection settings
timeout: float = Field(default=30.0, description="Request timeout in seconds")
max_retries: int = Field(default=3, description="Maximum retry attempts")
verify_ssl: bool = Field(default=True, description="Verify SSL certificates")
# Feature flags
enabled: bool = Field(default=True, description="Whether repository is enabled")
use_cache: bool = Field(default=True, description="Whether to cache responses")
@field_validator("priority")
@classmethod
def validate_priority(cls, v: int) -> int:
"""Validate repository priority."""
if v < 1 or v > 1000:
raise ValueError("Priority must be between 1 and 1000")
return v
@field_validator("timeout")
@classmethod
def validate_timeout(cls, v: float) -> float:
"""Validate timeout."""
if v <= 0:
raise ValueError("Timeout must be positive")
return v
@field_validator("max_retries")
@classmethod
def validate_max_retries(cls, v: int) -> int:
"""Validate max retries."""
if v < 0 or v > 10:
raise ValueError("Max retries must be between 0 and 10")
return v
def requires_auth(self) -> bool:
"""Check if repository requires authentication."""
return self.auth_type != AuthType.NONE
def has_credentials(self) -> bool:
"""Check if repository has valid credentials."""
if self.auth_type == AuthType.BASIC:
return bool(self.username and self.password)
elif self.auth_type == AuthType.TOKEN:
return bool(self.token)
return True # No auth required
def get_safe_dict(self) -> dict[str, Any]:
"""Get repository config as dictionary with sensitive data masked."""
data = self.model_dump()
# Mask sensitive information
if data.get("password"):
data["password"] = "***"
if data.get("token"):
data["token"] = "***"
return data
class RepositoryManager:
"""Manager for repository configurations."""
def __init__(self):
"""Initialize repository manager."""
self._repositories: dict[str, RepositoryConfig] = {}
self._load_default_repositories()
def _load_default_repositories(self) -> None:
"""Load default repository configurations."""
# Add default public PyPI repository
public_repo = RepositoryConfig(
name="pypi",
url="https://pypi.org/pypi",
type=RepositoryType.PUBLIC,
priority=100,
auth_type=AuthType.NONE,
)
self._repositories["pypi"] = public_repo
def load_repositories_from_settings(self, settings) -> None:
"""Load repositories from settings configuration."""
# Clear existing repositories except default PyPI
repos_to_keep = {
name: repo
for name, repo in self._repositories.items()
if repo.type == RepositoryType.PUBLIC and name == "pypi"
}
self._repositories = repos_to_keep
# Add repositories from index URLs
all_urls = settings.get_all_index_urls()
primary_urls = settings.get_primary_index_urls()
fallback_urls = settings.get_fallback_index_urls()
# Update primary PyPI URL if different from default
if all_urls and all_urls[0] != "https://pypi.org/pypi":
self._repositories["pypi"].url = all_urls[0]
# Add additional primary index URLs
for i, url in enumerate(
primary_urls[1:], 1
): # Skip first URL (already set as primary)
repo_name = f"index_{i}"
repo = RepositoryConfig(
name=repo_name,
url=url,
type=RepositoryType.PUBLIC,
priority=100 + i, # Slightly lower priority than primary
auth_type=AuthType.NONE,
)
self._repositories[repo_name] = repo
# Add fallback index URLs
for i, url in enumerate(fallback_urls):
repo_name = f"fallback_{i}"
repo = RepositoryConfig(
name=repo_name,
url=url,
type=RepositoryType.PUBLIC,
priority=200 + i, # Lower priority for fallbacks
auth_type=AuthType.NONE,
)
self._repositories[repo_name] = repo
# Add private repository if configured
if settings.has_private_repo():
self.add_private_repository_from_settings(
settings.private_pypi_url,
settings.private_pypi_username,
settings.private_pypi_password,
)
def add_repository(self, repo: RepositoryConfig) -> None:
"""Add a repository configuration."""
if not repo.has_credentials() and repo.requires_auth():
raise ValueError(
f"Repository {repo.name} requires authentication but has no credentials"
)
self._repositories[repo.name] = repo
def remove_repository(self, name: str) -> None:
"""Remove a repository configuration."""
if name == "pypi":
raise ValueError("Cannot remove default PyPI repository")
self._repositories.pop(name, None)
def get_repository(self, name: str) -> RepositoryConfig | None:
"""Get repository configuration by name."""
return self._repositories.get(name)
def list_repositories(self) -> list[RepositoryConfig]:
"""List all repository configurations."""
return list(self._repositories.values())
def get_enabled_repositories(self) -> list[RepositoryConfig]:
"""Get all enabled repositories sorted by priority."""
enabled = [repo for repo in self._repositories.values() if repo.enabled]
return sorted(enabled, key=lambda x: x.priority)
def get_private_repositories(self) -> list[RepositoryConfig]:
"""Get all private repositories."""
return [
repo
for repo in self._repositories.values()
if repo.type == RepositoryType.PRIVATE and repo.enabled
]
def has_private_repositories(self) -> bool:
"""Check if any private repositories are configured."""
return len(self.get_private_repositories()) > 0
def add_private_repository_from_settings(
self, url: str, username: str | None = None, password: str | None = None
) -> None:
"""Add private repository from settings."""
if not url:
return
auth_type = AuthType.BASIC if username and password else AuthType.NONE
private_repo = RepositoryConfig(
name="private",
url=url,
type=RepositoryType.PRIVATE,
priority=1, # Higher priority than public
auth_type=auth_type,
username=username,
password=password,
)
self.add_repository(private_repo)
# Global repository manager instance
_repository_manager: RepositoryManager | None = None
def get_repository_manager() -> RepositoryManager:
"""Get global repository manager instance."""
global _repository_manager
if _repository_manager is None:
_repository_manager = RepositoryManager()
return _repository_manager
def reload_repository_manager() -> RepositoryManager:
"""Reload repository manager."""
global _repository_manager
_repository_manager = RepositoryManager()
return _repository_manager

View file

@ -0,0 +1,198 @@
"""Configuration settings for PyPI Query MCP Server."""
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class ServerSettings(BaseSettings):
"""Server configuration settings."""
model_config = SettingsConfigDict(
env_prefix="PYPI_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Basic server settings
log_level: str = Field(default="INFO", description="Logging level")
cache_ttl: int = Field(default=3600, description="Cache time-to-live in seconds")
request_timeout: float = Field(
default=30.0, description="HTTP request timeout in seconds"
)
max_retries: int = Field(default=3, description="Maximum number of retry attempts")
retry_delay: float = Field(
default=1.0, description="Delay between retries in seconds"
)
# PyPI settings
index_url: str = Field(
default="https://pypi.org/pypi", description="Primary PyPI index URL"
)
index_urls: str | None = Field(
default=None, description="Additional PyPI index URLs (comma-separated)"
)
extra_index_urls: str | None = Field(
default=None, description="Extra PyPI index URLs for fallback (comma-separated)"
)
# Private repository settings
private_pypi_url: str | None = Field(
default=None, description="Private PyPI repository URL"
)
private_pypi_username: str | None = Field(
default=None, description="Private PyPI username"
)
private_pypi_password: str | None = Field(
default=None, description="Private PyPI password"
)
# Advanced dependency analysis settings
dependency_max_depth: int = Field(
default=5, description="Maximum depth for recursive dependency analysis"
)
dependency_max_concurrent: int = Field(
default=10, description="Maximum concurrent dependency queries"
)
enable_security_analysis: bool = Field(
default=False, description="Enable security vulnerability analysis"
)
@field_validator("log_level")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate log level."""
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if v.upper() not in valid_levels:
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
return v.upper()
@field_validator("cache_ttl")
@classmethod
def validate_cache_ttl(cls, v: int) -> int:
"""Validate cache TTL."""
if v < 0:
raise ValueError("Cache TTL must be non-negative")
return v
@field_validator("dependency_max_depth")
@classmethod
def validate_dependency_max_depth(cls, v: int) -> int:
"""Validate dependency analysis max depth."""
if v < 1 or v > 10:
raise ValueError("Dependency max depth must be between 1 and 10")
return v
@field_validator("dependency_max_concurrent")
@classmethod
def validate_dependency_max_concurrent(cls, v: int) -> int:
"""Validate max concurrent dependency queries."""
if v < 1 or v > 50:
raise ValueError("Max concurrent queries must be between 1 and 50")
return v
def has_private_repo(self) -> bool:
"""Check if private repository is configured."""
return bool(self.private_pypi_url)
def has_private_auth(self) -> bool:
"""Check if private repository authentication is configured."""
return bool(
self.private_pypi_url
and self.private_pypi_username
and self.private_pypi_password
)
def get_all_index_urls(self) -> list[str]:
"""Get all configured index URLs in priority order."""
urls = [self.index_url]
# Add additional index URLs
if self.index_urls:
additional_urls = [
url.strip() for url in self.index_urls.split(",") if url.strip()
]
urls.extend(additional_urls)
# Add extra index URLs (lower priority)
if self.extra_index_urls:
extra_urls = [
url.strip() for url in self.extra_index_urls.split(",") if url.strip()
]
urls.extend(extra_urls)
# Remove duplicates while preserving order
seen = set()
unique_urls = []
for url in urls:
if url not in seen:
seen.add(url)
unique_urls.append(url)
return unique_urls
def get_primary_index_urls(self) -> list[str]:
"""Get primary index URLs (excluding extra fallback URLs)."""
urls = [self.index_url]
if self.index_urls:
additional_urls = [
url.strip() for url in self.index_urls.split(",") if url.strip()
]
urls.extend(additional_urls)
# Remove duplicates while preserving order
seen = set()
unique_urls = []
for url in urls:
if url not in seen:
seen.add(url)
unique_urls.append(url)
return unique_urls
def get_fallback_index_urls(self) -> list[str]:
"""Get fallback index URLs."""
if not self.extra_index_urls:
return []
return [url.strip() for url in self.extra_index_urls.split(",") if url.strip()]
def get_safe_dict(self) -> dict[str, Any]:
"""Get configuration as dictionary with sensitive data masked."""
data = self.model_dump()
# Mask sensitive information
if data.get("private_pypi_password"):
data["private_pypi_password"] = "***"
return data
# Global settings instance
_settings: ServerSettings | None = None
def get_settings() -> ServerSettings:
"""Get global settings instance."""
global _settings
if _settings is None:
_settings = ServerSettings()
return _settings
def reload_settings() -> ServerSettings:
"""Reload settings from environment variables."""
global _settings
_settings = ServerSettings()
return _settings
def update_settings(**kwargs: Any) -> ServerSettings:
"""Update settings with new values."""
global _settings
current_data = _settings.model_dump() if _settings else {}
current_data.update(kwargs)
_settings = ServerSettings(**current_data)
return _settings

View file

@ -99,6 +99,7 @@ class PyPIClient:
def _is_cache_valid(self, cache_entry: dict[str, Any]) -> bool:
"""Check if cache entry is still valid."""
import time
return time.time() - cache_entry.get("timestamp", 0) < self._cache_ttl
async def _make_request(self, url: str) -> dict[str, Any]:
@ -140,7 +141,7 @@ class PyPIClient:
else:
raise PyPIServerError(
response.status_code,
f"Unexpected status code: {response.status_code}"
f"Unexpected status code: {response.status_code}",
)
except httpx.TimeoutException as e:
@ -155,12 +156,16 @@ class PyPIClient:
# Wait before retry (except on last attempt)
if attempt < self.max_retries:
await asyncio.sleep(self.retry_delay * (2 ** attempt)) # Exponential backoff
await asyncio.sleep(
self.retry_delay * (2**attempt)
) # Exponential backoff
# If we get here, all retries failed
raise last_exception
async def get_package_info(self, package_name: str, use_cache: bool = True) -> dict[str, Any]:
async def get_package_info(
self, package_name: str, use_cache: bool = True
) -> dict[str, Any]:
"""Get comprehensive package information from PyPI.
Args:
@ -194,10 +199,8 @@ class PyPIClient:
# Cache the result
import time
self._cache[cache_key] = {
"data": data,
"timestamp": time.time()
}
self._cache[cache_key] = {"data": data, "timestamp": time.time()}
return data
@ -205,7 +208,9 @@ class PyPIClient:
logger.error(f"Failed to fetch package info for {normalized_name}: {e}")
raise
async def get_package_versions(self, package_name: str, use_cache: bool = True) -> list[str]:
async def get_package_versions(
self, package_name: str, use_cache: bool = True
) -> list[str]:
"""Get list of available versions for a package.
Args:
@ -219,7 +224,9 @@ class PyPIClient:
releases = package_info.get("releases", {})
return list(releases.keys())
async def get_latest_version(self, package_name: str, use_cache: bool = True) -> str:
async def get_latest_version(
self, package_name: str, use_cache: bool = True
) -> str:
"""Get the latest version of a package.
Args:

View file

@ -45,7 +45,9 @@ class VersionCompatibility:
logger.warning(f"Failed to parse requires_python '{requires_python}': {e}")
return None
def extract_python_versions_from_classifiers(self, classifiers: list[str]) -> set[str]:
def extract_python_versions_from_classifiers(
self, classifiers: list[str]
) -> set[str]:
"""Extract Python version information from classifiers.
Args:
@ -87,7 +89,7 @@ class VersionCompatibility:
self,
target_version: str,
requires_python: str | None = None,
classifiers: list[str] | None = None
classifiers: list[str] | None = None,
) -> dict[str, Any]:
"""Check if a target Python version is compatible with package requirements.
@ -105,7 +107,7 @@ class VersionCompatibility:
"compatibility_source": None,
"details": {},
"warnings": [],
"suggestions": []
"suggestions": [],
}
try:
@ -119,15 +121,17 @@ class VersionCompatibility:
spec_set = self.parse_requires_python(requires_python)
if spec_set:
is_compatible = target_ver in spec_set
result.update({
"is_compatible": is_compatible,
"compatibility_source": "requires_python",
"details": {
"requires_python": requires_python,
"parsed_spec": str(spec_set),
"check_result": is_compatible
result.update(
{
"is_compatible": is_compatible,
"compatibility_source": "requires_python",
"details": {
"requires_python": requires_python,
"parsed_spec": str(spec_set),
"check_result": is_compatible,
},
}
})
)
if not is_compatible:
result["suggestions"].append(
@ -139,7 +143,9 @@ class VersionCompatibility:
# Fall back to classifiers if no requires_python
if classifiers:
supported_versions = self.extract_python_versions_from_classifiers(classifiers)
supported_versions = self.extract_python_versions_from_classifiers(
classifiers
)
implementations = self.extract_python_implementations(classifiers)
if supported_versions:
@ -148,21 +154,23 @@ class VersionCompatibility:
target_major = str(target_ver.major)
is_compatible = (
target_version in supported_versions or
target_major_minor in supported_versions or
target_major in supported_versions
target_version in supported_versions
or target_major_minor in supported_versions
or target_major in supported_versions
)
result.update({
"is_compatible": is_compatible,
"compatibility_source": "classifiers",
"details": {
"supported_versions": sorted(supported_versions),
"implementations": sorted(implementations),
"target_major_minor": target_major_minor,
"check_result": is_compatible
result.update(
{
"is_compatible": is_compatible,
"compatibility_source": "classifiers",
"details": {
"supported_versions": sorted(supported_versions),
"implementations": sorted(implementations),
"target_major_minor": target_major_minor,
"check_result": is_compatible,
},
}
})
)
if not is_compatible:
result["suggestions"].append(
@ -186,7 +194,7 @@ class VersionCompatibility:
self,
requires_python: str | None = None,
classifiers: list[str] | None = None,
available_pythons: list[str] | None = None
available_pythons: list[str] | None = None,
) -> dict[str, Any]:
"""Get list of compatible Python versions for a package.
@ -200,9 +208,7 @@ class VersionCompatibility:
"""
if available_pythons is None:
# Default Python versions to check
available_pythons = [
"3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"
]
available_pythons = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
compatible = []
incompatible = []
@ -213,28 +219,34 @@ class VersionCompatibility:
)
if result["is_compatible"]:
compatible.append({
"version": python_version,
"source": result["compatibility_source"]
})
compatible.append(
{
"version": python_version,
"source": result["compatibility_source"],
}
)
else:
incompatible.append({
"version": python_version,
"reason": result["suggestions"][0] if result["suggestions"] else "Unknown"
})
incompatible.append(
{
"version": python_version,
"reason": result["suggestions"][0]
if result["suggestions"]
else "Unknown",
}
)
return {
"compatible_versions": compatible,
"incompatible_versions": incompatible,
"total_checked": len(available_pythons),
"compatibility_rate": len(compatible) / len(available_pythons) if available_pythons else 0,
"recommendations": self._generate_recommendations(compatible, incompatible)
"compatibility_rate": len(compatible) / len(available_pythons)
if available_pythons
else 0,
"recommendations": self._generate_recommendations(compatible, incompatible),
}
def _generate_recommendations(
self,
compatible: list[dict[str, Any]],
incompatible: list[dict[str, Any]]
self, compatible: list[dict[str, Any]], incompatible: list[dict[str, Any]]
) -> list[str]:
"""Generate recommendations based on compatibility results.

View file

@ -17,8 +17,7 @@ from .tools import (
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@ -60,14 +59,14 @@ async def get_package_info(package_name: str) -> dict[str, Any]:
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error querying package {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name
"package_name": package_name,
}
@ -103,19 +102,21 @@ async def get_package_versions(package_name: str) -> dict[str, Any]:
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error querying versions for {package_name}: {e}")
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name
"package_name": package_name,
}
@mcp.tool()
async def get_package_dependencies(package_name: str, version: str | None = None) -> dict[str, Any]:
async def get_package_dependencies(
package_name: str, version: str | None = None
) -> dict[str, Any]:
"""Get dependency information for a PyPI package.
This tool retrieves comprehensive dependency information for a Python package,
@ -138,8 +139,10 @@ async def get_package_dependencies(package_name: str, version: str | None = None
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Querying dependencies for {package_name}" +
(f" version {version}" if version else " (latest)"))
logger.info(
f"MCP tool: Querying dependencies for {package_name}"
+ (f" version {version}" if version else " (latest)")
)
result = await query_package_dependencies(package_name, version)
logger.info(f"Successfully retrieved dependencies for package: {package_name}")
return result
@ -149,7 +152,7 @@ async def get_package_dependencies(package_name: str, version: str | None = None
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"version": version
"version": version,
}
except Exception as e:
logger.error(f"Unexpected error querying dependencies for {package_name}: {e}")
@ -157,15 +160,13 @@ async def get_package_dependencies(package_name: str, version: str | None = None
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"version": version
"version": version,
}
@mcp.tool()
async def check_package_python_compatibility(
package_name: str,
target_python_version: str,
use_cache: bool = True
package_name: str, target_python_version: str, use_cache: bool = True
) -> dict[str, Any]:
"""Check if a package is compatible with a specific Python version.
@ -190,8 +191,12 @@ async def check_package_python_compatibility(
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Checking Python {target_python_version} compatibility for {package_name}")
result = await check_python_compatibility(package_name, target_python_version, use_cache)
logger.info(
f"MCP tool: Checking Python {target_python_version} compatibility for {package_name}"
)
result = await check_python_compatibility(
package_name, target_python_version, use_cache
)
logger.info(f"Compatibility check completed for {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
@ -200,7 +205,7 @@ async def check_package_python_compatibility(
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"target_python_version": target_python_version
"target_python_version": target_python_version,
}
except Exception as e:
logger.error(f"Unexpected error checking compatibility for {package_name}: {e}")
@ -208,15 +213,13 @@ async def check_package_python_compatibility(
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name,
"target_python_version": target_python_version
"target_python_version": target_python_version,
}
@mcp.tool()
async def get_package_compatible_python_versions(
package_name: str,
python_versions: list[str] | None = None,
use_cache: bool = True
package_name: str, python_versions: list[str] | None = None, use_cache: bool = True
) -> dict[str, Any]:
"""Get all Python versions compatible with a package.
@ -242,7 +245,9 @@ async def get_package_compatible_python_versions(
"""
try:
logger.info(f"MCP tool: Getting compatible Python versions for {package_name}")
result = await get_compatible_python_versions(package_name, python_versions, use_cache)
result = await get_compatible_python_versions(
package_name, python_versions, use_cache
)
logger.info(f"Compatible versions analysis completed for {package_name}")
return result
except (InvalidPackageNameError, PackageNotFoundError, NetworkError) as e:
@ -250,14 +255,16 @@ async def get_package_compatible_python_versions(
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name
"package_name": package_name,
}
except Exception as e:
logger.error(f"Unexpected error getting compatible versions for {package_name}: {e}")
logger.error(
f"Unexpected error getting compatible versions for {package_name}: {e}"
)
return {
"error": f"Unexpected error: {e}",
"error_type": "UnexpectedError",
"package_name": package_name
"package_name": package_name,
}
@ -266,7 +273,7 @@ async def get_package_compatible_python_versions(
"--log-level",
default="INFO",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]),
help="Logging level"
help="Logging level",
)
def main(log_level: str) -> None:
"""Start the PyPI Query MCP Server."""

View file

@ -10,9 +10,7 @@ logger = logging.getLogger(__name__)
async def check_python_compatibility(
package_name: str,
target_python_version: str,
use_cache: bool = True
package_name: str, target_python_version: str, use_cache: bool = True
) -> dict[str, Any]:
"""Check if a package is compatible with a specific Python version.
@ -35,7 +33,9 @@ async def check_python_compatibility(
if not target_python_version or not target_python_version.strip():
raise ValueError("Target Python version cannot be empty")
logger.info(f"Checking Python {target_python_version} compatibility for package: {package_name}")
logger.info(
f"Checking Python {target_python_version} compatibility for package: {package_name}"
)
try:
async with PyPIClient() as client:
@ -48,19 +48,25 @@ async def check_python_compatibility(
# Perform compatibility check
compat_checker = VersionCompatibility()
result = compat_checker.check_version_compatibility(
target_python_version,
requires_python,
classifiers
target_python_version, requires_python, classifiers
)
# Add package information to result
result.update({
"package_name": info.get("name", package_name),
"package_version": info.get("version", ""),
"requires_python": requires_python,
"supported_implementations": compat_checker.extract_python_implementations(classifiers),
"classifier_versions": sorted(compat_checker.extract_python_versions_from_classifiers(classifiers))
})
result.update(
{
"package_name": info.get("name", package_name),
"package_version": info.get("version", ""),
"requires_python": requires_python,
"supported_implementations": compat_checker.extract_python_implementations(
classifiers
),
"classifier_versions": sorted(
compat_checker.extract_python_versions_from_classifiers(
classifiers
)
),
}
)
return result
@ -73,9 +79,7 @@ async def check_python_compatibility(
async def get_compatible_python_versions(
package_name: str,
python_versions: list[str] | None = None,
use_cache: bool = True
package_name: str, python_versions: list[str] | None = None, use_cache: bool = True
) -> dict[str, Any]:
"""Get list of Python versions compatible with a package.
@ -108,19 +112,25 @@ async def get_compatible_python_versions(
# Get compatibility information
compat_checker = VersionCompatibility()
result = compat_checker.get_compatible_versions(
requires_python,
classifiers,
python_versions
requires_python, classifiers, python_versions
)
# Add package information to result
result.update({
"package_name": info.get("name", package_name),
"package_version": info.get("version", ""),
"requires_python": requires_python,
"supported_implementations": sorted(compat_checker.extract_python_implementations(classifiers)),
"classifier_versions": sorted(compat_checker.extract_python_versions_from_classifiers(classifiers))
})
result.update(
{
"package_name": info.get("name", package_name),
"package_version": info.get("version", ""),
"requires_python": requires_python,
"supported_implementations": sorted(
compat_checker.extract_python_implementations(classifiers)
),
"classifier_versions": sorted(
compat_checker.extract_python_versions_from_classifiers(
classifiers
)
),
}
)
return result
@ -128,13 +138,14 @@ async def get_compatible_python_versions(
# Re-raise PyPI-specific errors
raise
except Exception as e:
logger.error(f"Unexpected error getting compatible versions for {package_name}: {e}")
logger.error(
f"Unexpected error getting compatible versions for {package_name}: {e}"
)
raise NetworkError(f"Failed to get compatible Python versions: {e}", e) from e
async def suggest_python_version_for_packages(
package_names: list[str],
use_cache: bool = True
package_names: list[str], use_cache: bool = True
) -> dict[str, Any]:
"""Suggest optimal Python version for a list of packages.
@ -152,7 +163,9 @@ async def suggest_python_version_for_packages(
if not package_names:
raise ValueError("Package names list cannot be empty")
logger.info(f"Analyzing Python version compatibility for {len(package_names)} packages")
logger.info(
f"Analyzing Python version compatibility for {len(package_names)} packages"
)
# Default Python versions to analyze
python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
@ -172,20 +185,20 @@ async def suggest_python_version_for_packages(
compat_checker = VersionCompatibility()
compat_result = compat_checker.get_compatible_versions(
requires_python,
classifiers,
python_versions
requires_python, classifiers, python_versions
)
# Store compatibility for this package
compatible_versions = [v["version"] for v in compat_result["compatible_versions"]]
compatible_versions = [
v["version"] for v in compat_result["compatible_versions"]
]
compatibility_matrix[package_name] = compatible_versions
package_details[package_name] = {
"version": info.get("version", ""),
"requires_python": requires_python,
"compatible_versions": compatible_versions,
"compatibility_rate": compat_result["compatibility_rate"]
"compatibility_rate": compat_result["compatibility_rate"],
}
except Exception as e:
@ -207,14 +220,18 @@ async def suggest_python_version_for_packages(
# Generate recommendations
recommendations = []
if common_versions:
latest_common = max(common_versions, key=lambda x: tuple(map(int, x.split("."))))
latest_common = max(
common_versions, key=lambda x: tuple(map(int, x.split(".")))
)
recommendations.append(
f"✅ Recommended Python version: {latest_common} "
f"(compatible with all {len([p for p in compatibility_matrix if compatibility_matrix[p]])} packages)"
)
if len(common_versions) > 1:
all_common = sorted(common_versions, key=lambda x: tuple(map(int, x.split("."))))
all_common = sorted(
common_versions, key=lambda x: tuple(map(int, x.split(".")))
)
recommendations.append(
f"📋 All compatible versions: {', '.join(all_common)}"
)
@ -227,13 +244,19 @@ async def suggest_python_version_for_packages(
# Find the version compatible with most packages
version_scores = {}
for version in python_versions:
score = sum(1 for compatible in compatibility_matrix.values() if version in compatible)
score = sum(
1
for compatible in compatibility_matrix.values()
if version in compatible
)
version_scores[version] = score
if version_scores:
best_version = max(version_scores, key=version_scores.get)
best_score = version_scores[best_version]
total_packages = len([p for p in compatibility_matrix if compatibility_matrix[p]])
total_packages = len(
[p for p in compatibility_matrix if compatibility_matrix[p]]
)
if best_score > 0:
recommendations.append(
@ -246,10 +269,14 @@ async def suggest_python_version_for_packages(
"successful_analyses": len(package_details),
"failed_analyses": len(errors),
"common_compatible_versions": sorted(common_versions),
"recommended_version": max(common_versions, key=lambda x: tuple(map(int, x.split(".")))) if common_versions else None,
"recommended_version": max(
common_versions, key=lambda x: tuple(map(int, x.split(".")))
)
if common_versions
else None,
"compatibility_matrix": compatibility_matrix,
"package_details": package_details,
"errors": errors,
"recommendations": recommendations,
"python_versions_analyzed": python_versions
"python_versions_analyzed": python_versions,
}

View file

@ -24,7 +24,9 @@ def format_package_info(package_data: dict[str, Any]) -> dict[str, Any]:
"name": info.get("name", ""),
"version": info.get("version", ""),
"summary": info.get("summary", ""),
"description": info.get("description", "")[:500] + "..." if len(info.get("description", "")) > 500 else info.get("description", ""),
"description": info.get("description", "")[:500] + "..."
if len(info.get("description", "")) > 500
else info.get("description", ""),
"author": info.get("author", ""),
"author_email": info.get("author_email", ""),
"maintainer": info.get("maintainer", ""),
@ -53,7 +55,13 @@ def format_package_info(package_data: dict[str, Any]) -> dict[str, Any]:
formatted["download_info"] = {
"files_count": len(urls),
"file_types": list({url.get("packagetype", "") for url in urls}),
"python_versions": list({url.get("python_version", "") for url in urls if url.get("python_version")}),
"python_versions": list(
{
url.get("python_version", "")
for url in urls
if url.get("python_version")
}
),
}
return formatted
@ -83,11 +91,16 @@ def format_version_info(package_data: dict[str, Any]) -> dict[str, Any]:
"version_details": {
version: {
"release_count": len(releases[version]),
"has_wheel": any(file.get("packagetype") == "bdist_wheel" for file in releases[version]),
"has_source": any(file.get("packagetype") == "sdist" for file in releases[version]),
"has_wheel": any(
file.get("packagetype") == "bdist_wheel"
for file in releases[version]
),
"has_source": any(
file.get("packagetype") == "sdist" for file in releases[version]
),
}
for version in sorted_versions[:10] # Details for last 10 versions
}
},
}
@ -120,7 +133,7 @@ def format_dependency_info(package_data: dict[str, Any]) -> dict[str, Any]:
extra_part = parts[1] if len(parts) > 1 else ""
if "extra ==" in extra_part:
extra_name = extra_part.split("extra ==")[1].strip().strip('"\'')
extra_name = extra_part.split("extra ==")[1].strip().strip("\"'")
if extra_name not in optional_deps:
optional_deps[extra_name] = []
optional_deps[extra_name].append(dep_name)
@ -142,7 +155,7 @@ def format_dependency_info(package_data: dict[str, Any]) -> dict[str, Any]:
"dev_count": len(dev_deps),
"optional_groups": len(optional_deps),
"total_optional": sum(len(deps) for deps in optional_deps.values()),
}
},
}
@ -208,7 +221,9 @@ async def query_package_versions(package_name: str) -> dict[str, Any]:
raise NetworkError(f"Failed to query package versions: {e}", e) from e
async def query_package_dependencies(package_name: str, version: str | None = None) -> dict[str, Any]:
async def query_package_dependencies(
package_name: str, version: str | None = None
) -> dict[str, Any]:
"""Query package dependency information from PyPI.
Args:
@ -226,8 +241,10 @@ async def query_package_dependencies(package_name: str, version: str | None = No
if not package_name or not package_name.strip():
raise InvalidPackageNameError(package_name)
logger.info(f"Querying dependencies for package: {package_name}" +
(f" version {version}" if version else " (latest)"))
logger.info(
f"Querying dependencies for package: {package_name}"
+ (f" version {version}" if version else " (latest)")
)
try:
async with PyPIClient() as client:
@ -236,8 +253,10 @@ async def query_package_dependencies(package_name: str, version: str | None = No
# TODO: In future, support querying specific version dependencies
# For now, we return dependencies for the latest version
if version and version != package_data.get("info", {}).get("version"):
logger.warning(f"Specific version {version} requested but not implemented yet. "
f"Returning dependencies for latest version.")
logger.warning(
f"Specific version {version} requested but not implemented yet. "
f"Returning dependencies for latest version."
)
return format_dependency_info(package_data)
except PyPIError: