pypi-query-mcp/pypi_query_mcp/tools/compatibility_check.py

283 lines
10 KiB
Python
Raw Normal View History

"""Python version compatibility checking tools for PyPI MCP server."""
import logging
from typing import Any
from ..core import InvalidPackageNameError, NetworkError, PyPIClient, PyPIError
from ..core.version_utils import VersionCompatibility
logger = logging.getLogger(__name__)
async def check_python_compatibility(
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.
Args:
package_name: Name of the package to check
target_python_version: Target Python version (e.g., "3.9", "3.10.5")
use_cache: Whether to use cached package data
Returns:
Dictionary containing compatibility information
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
if not package_name or not package_name.strip():
raise InvalidPackageNameError(package_name)
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}"
)
try:
async with PyPIClient() as client:
package_data = await client.get_package_info(package_name, use_cache=use_cache)
info = package_data.get("info", {})
requires_python = info.get("requires_python")
classifiers = info.get("classifiers", [])
# Perform compatibility check
compat_checker = VersionCompatibility()
result = compat_checker.check_version_compatibility(
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
)
),
}
)
return result
except PyPIError:
# Re-raise PyPI-specific errors
raise
except Exception as e:
logger.error(f"Unexpected error checking compatibility for {package_name}: {e}")
raise NetworkError(f"Failed to check Python compatibility: {e}", e) from e
async def get_compatible_python_versions(
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.
Args:
package_name: Name of the package to check
python_versions: List of Python versions to check (optional)
use_cache: Whether to use cached package data
Returns:
Dictionary containing compatibility information for multiple versions
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
if not package_name or not package_name.strip():
raise InvalidPackageNameError(package_name)
logger.info(f"Getting compatible Python versions for package: {package_name}")
try:
async with PyPIClient() as client:
package_data = await client.get_package_info(package_name, use_cache=use_cache)
info = package_data.get("info", {})
requires_python = info.get("requires_python")
classifiers = info.get("classifiers", [])
# Get compatibility information
compat_checker = VersionCompatibility()
result = compat_checker.get_compatible_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
)
),
}
)
return result
except PyPIError:
# Re-raise PyPI-specific errors
raise
except Exception as 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
) -> dict[str, Any]:
"""Suggest optimal Python version for a list of packages.
Args:
package_names: List of package names to analyze
use_cache: Whether to use cached package data
Returns:
Dictionary containing version suggestions and compatibility matrix
Raises:
ValueError: If package_names is empty
NetworkError: For network-related errors
"""
if not package_names:
raise ValueError("Package names list cannot be empty")
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"]
compatibility_matrix = {}
package_details = {}
errors = {}
async with PyPIClient() as client:
for package_name in package_names:
try:
package_data = await client.get_package_info(package_name, use_cache=use_cache)
info = package_data.get("info", {})
requires_python = info.get("requires_python")
classifiers = info.get("classifiers", [])
compat_checker = VersionCompatibility()
compat_result = compat_checker.get_compatible_versions(
requires_python, classifiers, python_versions
)
# Store compatibility for this package
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"],
}
except Exception as e:
logger.warning(f"Failed to analyze package {package_name}: {e}")
errors[package_name] = str(e)
compatibility_matrix[package_name] = []
# Find common compatible versions
if compatibility_matrix:
all_versions = set(python_versions)
common_versions = all_versions.copy()
for _package_name, compatible in compatibility_matrix.items():
if compatible: # Only consider packages with known compatibility
common_versions &= set(compatible)
else:
common_versions = set()
# Generate recommendations
recommendations = []
if common_versions:
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(".")))
)
recommendations.append(
f"📋 All compatible versions: {', '.join(all_common)}"
)
else:
recommendations.append(
"⚠️ No Python version is compatible with all packages. "
"Consider updating packages or using different versions."
)
# 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
)
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]]
)
if best_score > 0:
recommendations.append(
f"📊 Best compromise: Python {best_version} "
f"(compatible with {best_score}/{total_packages} packages)"
)
return {
"analyzed_packages": len(package_names),
"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,
"compatibility_matrix": compatibility_matrix,
"package_details": package_details,
"errors": errors,
"recommendations": recommendations,
"python_versions_analyzed": python_versions,
}