pypi-query-mcp/pypi_query_mcp/core/version_utils.py

346 lines
12 KiB
Python
Raw Normal View History

"""Version parsing and compatibility checking utilities."""
import logging
import re
from typing import Any
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
logger = logging.getLogger(__name__)
class VersionCompatibility:
"""Utility class for Python version compatibility checking."""
def __init__(self):
"""Initialize version compatibility checker."""
# Common Python version patterns in classifiers
self.python_classifier_pattern = re.compile(
r"Programming Language :: Python :: (\d+(?:\.\d+)*)"
)
# Implementation-specific classifiers
self.implementation_pattern = re.compile(
r"Programming Language :: Python :: Implementation :: (\w+)"
)
def parse_requires_python(self, requires_python: str) -> SpecifierSet | None:
"""Parse requires_python field into a SpecifierSet.
Args:
requires_python: The requires_python string from package metadata
Returns:
SpecifierSet object or None if parsing fails
"""
if not requires_python or not requires_python.strip():
return None
try:
# Clean up the version specification
cleaned = requires_python.strip()
return SpecifierSet(cleaned)
except Exception as e:
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]:
"""Extract Python version information from classifiers.
Args:
classifiers: List of classifier strings
Returns:
Set of Python version strings
"""
versions = set()
for classifier in classifiers:
match = self.python_classifier_pattern.search(classifier)
if match:
version = match.group(1)
versions.add(version)
return versions
def extract_python_implementations(self, classifiers: list[str]) -> set[str]:
"""Extract Python implementation information from classifiers.
Args:
classifiers: List of classifier strings
Returns:
Set of Python implementation names (CPython, PyPy, etc.)
"""
implementations = set()
for classifier in classifiers:
match = self.implementation_pattern.search(classifier)
if match:
implementation = match.group(1)
implementations.add(implementation)
return implementations
def check_version_compatibility(
self,
target_version: str,
requires_python: str | None = None,
classifiers: list[str] | None = None,
) -> dict[str, Any]:
"""Check if a target Python version is compatible with package requirements.
Args:
target_version: Target Python version (e.g., "3.9", "3.10.5")
requires_python: The requires_python specification
classifiers: List of package classifiers
Returns:
Dictionary containing compatibility information
"""
result = {
"target_version": target_version,
"is_compatible": False,
"compatibility_source": None,
"details": {},
"warnings": [],
"suggestions": [],
}
try:
target_ver = Version(target_version)
except InvalidVersion as e:
result["warnings"].append(f"Invalid target version format: {e}")
return result
# Check requires_python first (more authoritative)
if requires_python:
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,
},
}
)
if not is_compatible:
result["suggestions"].append(
f"Package requires Python {requires_python}, "
f"but target is {target_version}"
)
return result
# Fall back to classifiers if no requires_python
if classifiers:
supported_versions = self.extract_python_versions_from_classifiers(
classifiers
)
implementations = self.extract_python_implementations(classifiers)
if supported_versions:
# Check if target version matches any supported version
target_major_minor = f"{target_ver.major}.{target_ver.minor}"
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
)
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(
f"Package supports Python versions: {', '.join(sorted(supported_versions))}, "
f"but target is {target_version}"
)
return result
# No version information available
result["warnings"].append(
"No Python version requirements found in package metadata"
)
result["suggestions"].append(
"Consider checking package documentation for Python version compatibility"
)
return result
def get_compatible_versions(
self,
requires_python: str | None = None,
classifiers: list[str] | None = None,
available_pythons: list[str] | None = None,
) -> dict[str, Any]:
"""Get list of compatible Python versions for a package.
Args:
requires_python: The requires_python specification
classifiers: List of package classifiers
available_pythons: List of Python versions to check against
Returns:
Dictionary containing compatible versions and recommendations
"""
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"]
compatible = []
incompatible = []
for python_version in available_pythons:
result = self.check_version_compatibility(
python_version, requires_python, classifiers
)
if result["is_compatible"]:
compatible.append(
{
"version": python_version,
"source": result["compatibility_source"],
}
)
else:
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),
}
def _generate_recommendations(
self, compatible: list[dict[str, Any]], incompatible: list[dict[str, Any]]
) -> list[str]:
"""Generate recommendations based on compatibility results.
Args:
compatible: List of compatible versions
incompatible: List of incompatible versions
Returns:
List of recommendation strings
"""
recommendations = []
if not compatible:
recommendations.append(
"⚠️ No compatible Python versions found. "
"Check package documentation for requirements."
)
elif len(compatible) == 1:
version = compatible[0]["version"]
recommendations.append(
f"📌 Only Python {version} is compatible with this package."
)
else:
versions = [v["version"] for v in compatible]
latest = max(versions, key=lambda x: tuple(map(int, x.split("."))))
recommendations.append(
f"✅ Compatible with Python {', '.join(versions)}. "
f"Recommended: Python {latest}"
)
if len(incompatible) > len(compatible):
recommendations.append(
"⚠️ This package has limited Python version support. "
"Consider using a more recent version of the package if available."
)
return recommendations
def sort_versions_semantically(versions: list[str], reverse: bool = True) -> list[str]:
"""Sort package versions using semantic version ordering.
This function properly sorts versions by parsing them as semantic versions,
ensuring that pre-release versions (alpha, beta, rc) are ordered correctly
relative to stable releases.
Args:
versions: List of version strings to sort
reverse: If True, sort in descending order (newest first). Default True.
Returns:
List of version strings sorted semantically
Examples:
>>> sort_versions_semantically(['1.0.0', '2.0.0a1', '1.5.0', '2.0.0'])
['2.0.0', '2.0.0a1', '1.5.0', '1.0.0']
>>> sort_versions_semantically(['5.2rc1', '5.2.5', '5.2.0'])
['5.2.5', '5.2.0', '5.2rc1']
"""
if not versions:
return []
def parse_version_safe(version_str: str) -> tuple[Version | None, str]:
"""Safely parse a version string, returning (parsed_version, original_string).
Returns (None, original_string) if parsing fails.
"""
try:
return (Version(version_str), version_str)
except InvalidVersion:
logger.debug(f"Failed to parse version '{version_str}' as semantic version")
return (None, version_str)
# Parse all versions, keeping track of originals
parsed_versions = [parse_version_safe(v) for v in versions]
# Separate valid and invalid versions
valid_versions = [(v, orig) for v, orig in parsed_versions if v is not None]
invalid_versions = [orig for v, orig in parsed_versions if v is None]
# Sort valid versions semantically
valid_versions.sort(key=lambda x: x[0], reverse=reverse)
# Sort invalid versions lexicographically as fallback
invalid_versions.sort(reverse=reverse)
# Combine results: valid versions first, then invalid ones
result = [orig for _, orig in valid_versions] + invalid_versions
logger.debug(
f"Sorted {len(versions)} versions: {len(valid_versions)} valid, "
f"{len(invalid_versions)} invalid"
)
return result